diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 03f2a565..586a401a 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -38,6 +38,7 @@ export interface Args { themes?: string[]; noThemes?: boolean; listModels?: string | true; + offline?: boolean; verbose?: boolean; messages: string[]; fileArgs: string[]; @@ -151,6 +152,8 @@ export function parseArgs(args: string[], extensionFlags?: Map Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --verbose Force verbose startup (overrides quietStartup setting) + --offline Disable startup network operations (same as PI_OFFLINE=1) --help, -h Show this help --version, -v Show version number @@ -295,6 +299,7 @@ ${chalk.bold("Environment Variables:")} AWS_REGION - AWS region for Amazon Bedrock (e.g., us-east-1) ${ENV_AGENT_DIR.padEnd(32)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) + PI_OFFLINE - Disable startup network operations when set to 1/true/yes PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) PI_AI_ANTIGRAVITY_VERSION - Override Antigravity User-Agent version (e.g., 1.23.0) diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 5ac0973f..43240821 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -9,6 +9,14 @@ import { CONFIG_DIR_NAME } from "../config.js"; import { type GitSource, parseGitUrl } from "../utils/git.js"; import type { PackageSource, SettingsManager } from "./settings-manager.js"; +const NETWORK_TIMEOUT_MS = 10000; + +function isOfflineModeEnabled(): boolean { + const value = process.env.PI_OFFLINE; + if (!value) return false; + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} + export interface PathMetadata { source: string; scope: SourceScope; @@ -842,6 +850,9 @@ export class DefaultPackageManager implements PackageManager { } private async updateSourceForScope(source: string, scope: SourceScope): Promise { + if (isOfflineModeEnabled()) { + return; + } const parsed = this.parseSource(source); if (parsed.type === "npm") { if (parsed.pinned) return; @@ -877,6 +888,9 @@ export class DefaultPackageManager implements PackageManager { } const installMissing = async (): Promise => { + if (isOfflineModeEnabled()) { + return false; + } if (!onMissing) { await this.installParsedSource(parsed, scope); return true; @@ -905,7 +919,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(installedPath)) { const installed = await installMissing(); if (!installed) continue; - } else if (scope === "temporary" && !parsed.pinned) { + } else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) { await this.refreshTemporaryGitSource(parsed, sourceStr); } metadata.baseDir = installedPath; @@ -1039,6 +1053,10 @@ export class DefaultPackageManager implements PackageManager { * - For pinned packages: check if installed version matches the pinned version */ private async npmNeedsUpdate(source: NpmSource, installedPath: string): Promise { + if (isOfflineModeEnabled()) { + return false; + } + const installedVersion = this.getInstalledNpmVersion(installedPath); if (!installedVersion) return true; @@ -1071,7 +1089,9 @@ export class DefaultPackageManager implements PackageManager { } private async getLatestNpmVersion(packageName: string): Promise { - const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`); + const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { + signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS), + }); if (!response.ok) throw new Error(`Failed to fetch npm registry: ${response.status}`); const data = (await response.json()) as { version: string }; return data.version; @@ -1207,6 +1227,9 @@ export class DefaultPackageManager implements PackageManager { } private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise { + if (isOfflineModeEnabled()) { + return; + } try { await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => { await this.updateGit(source, "temporary"); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e090931a..a061e778 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -65,6 +65,11 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string) } } +function isTruthyEnvFlag(value: string | undefined): boolean { + if (!value) return false; + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} + type PackageCommand = "install" | "remove" | "update" | "list"; interface PackageCommandOptions { @@ -535,6 +540,12 @@ async function handleConfigCommand(args: string[]): Promise { } export async function main(args: string[]) { + const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE); + if (offlineMode) { + process.env.PI_OFFLINE = "1"; + process.env.PI_SKIP_VERSION_CHECK = "1"; + } + if (await handlePackageCommand(args)) { return; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 05e192e1..74a94a90 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -572,10 +572,12 @@ export class InteractiveMode { * Check npm registry for a newer version. */ private async checkForNewVersion(): Promise { - if (process.env.PI_SKIP_VERSION_CHECK) return undefined; + if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined; try { - const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest"); + const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest", { + signal: AbortSignal.timeout(10000), + }); if (!response.ok) return undefined; const data = (await response.json()) as { version?: string }; diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 3bbe5ac9..4aebd770 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -8,6 +8,13 @@ import { finished } from "stream/promises"; import { APP_NAME, getBinDir } from "../config.js"; const TOOLS_DIR = getBinDir(); +const NETWORK_TIMEOUT_MS = 10000; + +function isOfflineModeEnabled(): boolean { + const value = process.env.PI_OFFLINE; + if (!value) return false; + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} interface ToolConfig { name: string; @@ -94,6 +101,7 @@ export function getToolPath(tool: "fd" | "rg"): string | null { async function getLatestVersion(repo: string): Promise { const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers: { "User-Agent": `${APP_NAME}-coding-agent` }, + signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS), }); if (!response.ok) { @@ -106,7 +114,9 @@ async function getLatestVersion(repo: string): Promise { // Download a file from URL async function downloadFile(url: string, dest: string): Promise { - const response = await fetch(url); + const response = await fetch(url, { + signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS), + }); if (!response.ok) { throw new Error(`Failed to download: ${response.status}`); @@ -207,6 +217,13 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr const config = TOOLS[tool]; if (!config) return undefined; + if (isOfflineModeEnabled()) { + if (!silent) { + console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`)); + } + return undefined; + } + // On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility. // Users must install via pkg. if (platform() === "android") { diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 21b9af30..d388704c 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -227,6 +227,13 @@ describe("parseArgs", () => { }); }); + describe("--offline flag", () => { + test("parses --offline flag", () => { + const result = parseArgs(["--offline"]); + expect(result.offline).toBe(true); + }); + }); + describe("--no-tools flag", () => { test("parses --no-tools flag", () => { const result = parseArgs(["--no-tools"]); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 295abef8..f427b339 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; @@ -17,8 +17,11 @@ describe("DefaultPackageManager", () => { let agentDir: string; let settingsManager: SettingsManager; let packageManager: DefaultPackageManager; + let previousOfflineEnv: string | undefined; beforeEach(() => { + previousOfflineEnv = process.env.PI_OFFLINE; + delete process.env.PI_OFFLINE; tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tempDir, { recursive: true }); agentDir = join(tempDir, "agent"); @@ -33,16 +36,25 @@ describe("DefaultPackageManager", () => { }); afterEach(() => { + if (previousOfflineEnv === undefined) { + delete process.env.PI_OFFLINE; + } else { + process.env.PI_OFFLINE = previousOfflineEnv; + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); rmSync(tempDir, { recursive: true, force: true }); }); describe("resolve", () => { - it("should return empty paths when no sources configured", async () => { + it("should return no package-sourced paths when no sources configured", async () => { const result = await packageManager.resolve(); expect(result.extensions).toEqual([]); - expect(result.skills).toEqual([]); expect(result.prompts).toEqual([]); expect(result.themes).toEqual([]); + expect(result.skills.every((r) => r.metadata.source === "auto" && r.metadata.origin === "top-level")).toBe( + true, + ); }); it("should resolve local extension paths from settings", async () => { @@ -1153,4 +1165,66 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(result.extensions.filter((r) => r.enabled).length).toBe(1); }); }); + + describe("offline mode and network timeouts", () => { + it("should skip installing missing package sources when offline", async () => { + process.env.PI_OFFLINE = "1"; + settingsManager.setProjectPackages(["npm:missing-package", "git:github.com/example/missing-repo"]); + + const installParsedSourceSpy = vi.spyOn(packageManager as any, "installParsedSource"); + + const result = await packageManager.resolve(); + const allResources = [...result.extensions, ...result.skills, ...result.prompts, ...result.themes]; + expect(allResources.some((r) => r.metadata.origin === "package")).toBe(false); + expect(installParsedSourceSpy).not.toHaveBeenCalled(); + }); + + it("should skip refreshing temporary git sources when offline", async () => { + process.env.PI_OFFLINE = "1"; + const gitSource = "git:github.com/example/repo"; + const parsedGitSource = (packageManager as any).parseSource(gitSource); + const installedPath = (packageManager as any).getGitInstallPath(parsedGitSource, "temporary") as string; + + mkdirSync(join(installedPath, "extensions"), { recursive: true }); + writeFileSync(join(installedPath, "extensions", "index.ts"), "export default function() {};"); + + const refreshTemporaryGitSourceSpy = vi.spyOn(packageManager as any, "refreshTemporaryGitSource"); + + const result = await packageManager.resolveExtensionSources([gitSource], { temporary: true }); + expect(result.extensions.some((r) => r.path.endsWith("extensions/index.ts") && r.enabled)).toBe(true); + expect(refreshTemporaryGitSourceSpy).not.toHaveBeenCalled(); + }); + + it("should not call fetch in npmNeedsUpdate when offline", async () => { + process.env.PI_OFFLINE = "1"; + const installedPath = join(tempDir, "installed-package"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ version: "1.0.0" })); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const needsUpdate = await (packageManager as any).npmNeedsUpdate( + { type: "npm", spec: "example", name: "example", pinned: false }, + installedPath, + ); + + expect(needsUpdate).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should pass an AbortSignal timeout when fetching npm latest version", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: "1.2.3" }), + }); + vi.stubGlobal("fetch", fetchMock); + + const latest = await (packageManager as any).getLatestNpmVersion("example"); + expect(latest).toBe("1.2.3"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit | undefined]; + expect(options?.signal).toBeDefined(); + }); + }); });