mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 20:00:41 +00:00
fix(coding-agent): add offline startup mode and network timeouts (#1631)
This commit is contained in:
parent
f129ac93c5
commit
757d36a41b
7 changed files with 147 additions and 8 deletions
|
|
@ -38,6 +38,7 @@ export interface Args {
|
||||||
themes?: string[];
|
themes?: string[];
|
||||||
noThemes?: boolean;
|
noThemes?: boolean;
|
||||||
listModels?: string | true;
|
listModels?: string | true;
|
||||||
|
offline?: boolean;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
messages: string[];
|
messages: string[];
|
||||||
fileArgs: string[];
|
fileArgs: string[];
|
||||||
|
|
@ -151,6 +152,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
||||||
}
|
}
|
||||||
} else if (arg === "--verbose") {
|
} else if (arg === "--verbose") {
|
||||||
result.verbose = true;
|
result.verbose = true;
|
||||||
|
} else if (arg === "--offline") {
|
||||||
|
result.offline = true;
|
||||||
} else if (arg.startsWith("@")) {
|
} else if (arg.startsWith("@")) {
|
||||||
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
||||||
} else if (arg.startsWith("--") && extensionFlags) {
|
} else if (arg.startsWith("--") && extensionFlags) {
|
||||||
|
|
@ -217,6 +220,7 @@ ${chalk.bold("Options:")}
|
||||||
--export <file> Export session file to HTML and exit
|
--export <file> Export session file to HTML and exit
|
||||||
--list-models [search] List available models (with optional fuzzy search)
|
--list-models [search] List available models (with optional fuzzy search)
|
||||||
--verbose Force verbose startup (overrides quietStartup setting)
|
--verbose Force verbose startup (overrides quietStartup setting)
|
||||||
|
--offline Disable startup network operations (same as PI_OFFLINE=1)
|
||||||
--help, -h Show this help
|
--help, -h Show this help
|
||||||
--version, -v Show version number
|
--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)
|
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)
|
${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_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_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)
|
PI_AI_ANTIGRAVITY_VERSION - Override Antigravity User-Agent version (e.g., 1.23.0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ import { CONFIG_DIR_NAME } from "../config.js";
|
||||||
import { type GitSource, parseGitUrl } from "../utils/git.js";
|
import { type GitSource, parseGitUrl } from "../utils/git.js";
|
||||||
import type { PackageSource, SettingsManager } from "./settings-manager.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 {
|
export interface PathMetadata {
|
||||||
source: string;
|
source: string;
|
||||||
scope: SourceScope;
|
scope: SourceScope;
|
||||||
|
|
@ -842,6 +850,9 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateSourceForScope(source: string, scope: SourceScope): Promise<void> {
|
private async updateSourceForScope(source: string, scope: SourceScope): Promise<void> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parsed = this.parseSource(source);
|
const parsed = this.parseSource(source);
|
||||||
if (parsed.type === "npm") {
|
if (parsed.type === "npm") {
|
||||||
if (parsed.pinned) return;
|
if (parsed.pinned) return;
|
||||||
|
|
@ -877,6 +888,9 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const installMissing = async (): Promise<boolean> => {
|
const installMissing = async (): Promise<boolean> => {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!onMissing) {
|
if (!onMissing) {
|
||||||
await this.installParsedSource(parsed, scope);
|
await this.installParsedSource(parsed, scope);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -905,7 +919,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
if (!existsSync(installedPath)) {
|
if (!existsSync(installedPath)) {
|
||||||
const installed = await installMissing();
|
const installed = await installMissing();
|
||||||
if (!installed) continue;
|
if (!installed) continue;
|
||||||
} else if (scope === "temporary" && !parsed.pinned) {
|
} else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {
|
||||||
await this.refreshTemporaryGitSource(parsed, sourceStr);
|
await this.refreshTemporaryGitSource(parsed, sourceStr);
|
||||||
}
|
}
|
||||||
metadata.baseDir = installedPath;
|
metadata.baseDir = installedPath;
|
||||||
|
|
@ -1039,6 +1053,10 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
* - For pinned packages: check if installed version matches the pinned version
|
* - For pinned packages: check if installed version matches the pinned version
|
||||||
*/
|
*/
|
||||||
private async npmNeedsUpdate(source: NpmSource, installedPath: string): Promise<boolean> {
|
private async npmNeedsUpdate(source: NpmSource, installedPath: string): Promise<boolean> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const installedVersion = this.getInstalledNpmVersion(installedPath);
|
const installedVersion = this.getInstalledNpmVersion(installedPath);
|
||||||
if (!installedVersion) return true;
|
if (!installedVersion) return true;
|
||||||
|
|
||||||
|
|
@ -1071,7 +1089,9 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLatestNpmVersion(packageName: string): Promise<string> {
|
private async getLatestNpmVersion(packageName: string): Promise<string> {
|
||||||
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}`);
|
if (!response.ok) throw new Error(`Failed to fetch npm registry: ${response.status}`);
|
||||||
const data = (await response.json()) as { version: string };
|
const data = (await response.json()) as { version: string };
|
||||||
return data.version;
|
return data.version;
|
||||||
|
|
@ -1207,6 +1227,9 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise<void> {
|
private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise<void> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => {
|
await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => {
|
||||||
await this.updateGit(source, "temporary");
|
await this.updateGit(source, "temporary");
|
||||||
|
|
|
||||||
|
|
@ -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";
|
type PackageCommand = "install" | "remove" | "update" | "list";
|
||||||
|
|
||||||
interface PackageCommandOptions {
|
interface PackageCommandOptions {
|
||||||
|
|
@ -535,6 +540,12 @@ async function handleConfigCommand(args: string[]): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function main(args: string[]) {
|
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)) {
|
if (await handlePackageCommand(args)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -572,10 +572,12 @@ export class InteractiveMode {
|
||||||
* Check npm registry for a newer version.
|
* Check npm registry for a newer version.
|
||||||
*/
|
*/
|
||||||
private async checkForNewVersion(): Promise<string | undefined> {
|
private async checkForNewVersion(): Promise<string | undefined> {
|
||||||
if (process.env.PI_SKIP_VERSION_CHECK) return undefined;
|
if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined;
|
||||||
|
|
||||||
try {
|
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;
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
const data = (await response.json()) as { version?: string };
|
const data = (await response.json()) as { version?: string };
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import { finished } from "stream/promises";
|
||||||
import { APP_NAME, getBinDir } from "../config.js";
|
import { APP_NAME, getBinDir } from "../config.js";
|
||||||
|
|
||||||
const TOOLS_DIR = getBinDir();
|
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 {
|
interface ToolConfig {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -94,6 +101,7 @@ export function getToolPath(tool: "fd" | "rg"): string | null {
|
||||||
async function getLatestVersion(repo: string): Promise<string> {
|
async function getLatestVersion(repo: string): Promise<string> {
|
||||||
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||||
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
|
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
|
||||||
|
signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -106,7 +114,9 @@ async function getLatestVersion(repo: string): Promise<string> {
|
||||||
|
|
||||||
// Download a file from URL
|
// Download a file from URL
|
||||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to download: ${response.status}`);
|
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];
|
const config = TOOLS[tool];
|
||||||
if (!config) return undefined;
|
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.
|
// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.
|
||||||
// Users must install via pkg.
|
// Users must install via pkg.
|
||||||
if (platform() === "android") {
|
if (platform() === "android") {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
describe("--no-tools flag", () => {
|
||||||
test("parses --no-tools flag", () => {
|
test("parses --no-tools flag", () => {
|
||||||
const result = parseArgs(["--no-tools"]);
|
const result = parseArgs(["--no-tools"]);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, relative } from "node:path";
|
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 { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
|
|
||||||
|
|
@ -17,8 +17,11 @@ describe("DefaultPackageManager", () => {
|
||||||
let agentDir: string;
|
let agentDir: string;
|
||||||
let settingsManager: SettingsManager;
|
let settingsManager: SettingsManager;
|
||||||
let packageManager: DefaultPackageManager;
|
let packageManager: DefaultPackageManager;
|
||||||
|
let previousOfflineEnv: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
previousOfflineEnv = process.env.PI_OFFLINE;
|
||||||
|
delete process.env.PI_OFFLINE;
|
||||||
tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
mkdirSync(tempDir, { recursive: true });
|
mkdirSync(tempDir, { recursive: true });
|
||||||
agentDir = join(tempDir, "agent");
|
agentDir = join(tempDir, "agent");
|
||||||
|
|
@ -33,16 +36,25 @@ describe("DefaultPackageManager", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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 });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolve", () => {
|
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();
|
const result = await packageManager.resolve();
|
||||||
expect(result.extensions).toEqual([]);
|
expect(result.extensions).toEqual([]);
|
||||||
expect(result.skills).toEqual([]);
|
|
||||||
expect(result.prompts).toEqual([]);
|
expect(result.prompts).toEqual([]);
|
||||||
expect(result.themes).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 () => {
|
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);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue