Merge branch 'main' into dev
Surgical merge of 74 commits from main into dev. Key features ported: - Provider visibility gating (appVersion filtering for Pi/Copilot) - Pi agent provider + Copilot re-enabled - Provider-declared features system (Codex fast mode) - Workspace dedup by worktree root (adapted to integer IDs) - Bulk close archiving fix for stored agents - Agent creation timeout increase to 60s - Audio/voice crash fixes (external buffer copies) - Multi-host setup fix - Reload agent tab action Dev architecture preserved: SQLite storage, service proxy, workspace setup dialog, hover cards, removed launcher tabs.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyInvocation, isExistingDirectory, isPathLikeArg } from "./classify.js";
|
||||
|
||||
const knownCommands = new Set(["ls", "run", "status"]);
|
||||
|
||||
describe("classifyInvocation", () => {
|
||||
it("classifies no args as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({ kind: "cli", argv: [] });
|
||||
});
|
||||
|
||||
it("classifies flags as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["--version"],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({ kind: "cli", argv: ["--version"] });
|
||||
});
|
||||
|
||||
it("classifies known commands as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["ls", "--json"],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({ kind: "cli", argv: ["ls", "--json"] });
|
||||
});
|
||||
|
||||
it("classifies '.' as an open-project invocation", () => {
|
||||
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-dot-"));
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["."],
|
||||
knownCommands,
|
||||
cwd: projectDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies '..' as an open-project invocation", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-parent-"));
|
||||
const childDir = path.join(parentDir, "child");
|
||||
mkdirSync(childDir);
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [".."],
|
||||
knownCommands,
|
||||
cwd: childDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: parentDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies './myproject' as an open-project invocation", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-relative-"));
|
||||
const projectDir = path.join(parentDir, "myproject");
|
||||
mkdirSync(projectDir);
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["./myproject"],
|
||||
knownCommands,
|
||||
cwd: parentDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an absolute path as an open-project invocation", () => {
|
||||
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-absolute-"));
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [projectDir],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a home-relative path as an open-project invocation", () => {
|
||||
const projectDir = mkdtempSync(path.join(homedir(), "paseo-classify-home-"));
|
||||
const relativeToHome = `~/${path.basename(projectDir)}`;
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [relativeToHome],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an existing directory name as an open-project invocation", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-existing-"));
|
||||
const projectDir = path.join(parentDir, "myproject");
|
||||
mkdirSync(projectDir);
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["myproject"],
|
||||
knownCommands,
|
||||
cwd: parentDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps known commands in CLI mode even when a matching directory exists", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-command-"));
|
||||
mkdirSync(path.join(parentDir, "status"));
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["status"],
|
||||
knownCommands,
|
||||
cwd: parentDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "cli",
|
||||
argv: ["status"],
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies nonexistent args as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["nonexistent"],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "cli",
|
||||
argv: ["nonexistent"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("detects path-like prefixes", () => {
|
||||
expect(isPathLikeArg(".")).toBe(true);
|
||||
expect(isPathLikeArg("..")).toBe(true);
|
||||
expect(isPathLikeArg("./project")).toBe(true);
|
||||
expect(isPathLikeArg("../project")).toBe(true);
|
||||
expect(isPathLikeArg("/tmp/project")).toBe(true);
|
||||
expect(isPathLikeArg("~")).toBe(true);
|
||||
expect(isPathLikeArg("~/project")).toBe(true);
|
||||
expect(isPathLikeArg("C:\\project")).toBe(true);
|
||||
expect(isPathLikeArg("status")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects existing directories relative to cwd", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-helper-"));
|
||||
const projectDir = path.join(parentDir, "project");
|
||||
mkdirSync(projectDir);
|
||||
|
||||
expect(isExistingDirectory({ pathArg: "project", cwd: parentDir })).toBe(true);
|
||||
expect(isExistingDirectory({ pathArg: "missing", cwd: parentDir })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type CliInvocation =
|
||||
| { kind: "cli"; argv: string[] }
|
||||
| { kind: "open-project"; resolvedPath: string };
|
||||
|
||||
export function isPathLikeArg(arg: string): boolean {
|
||||
return (
|
||||
arg === "." ||
|
||||
arg === ".." ||
|
||||
arg.startsWith("./") ||
|
||||
arg.startsWith("../") ||
|
||||
arg.startsWith("/") ||
|
||||
arg === "~" ||
|
||||
arg.startsWith("~/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(arg)
|
||||
);
|
||||
}
|
||||
|
||||
export function expandUserPath(inputPath: string): string {
|
||||
if (inputPath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
|
||||
if (inputPath.startsWith("~/")) {
|
||||
return path.join(homedir(), inputPath.slice(2));
|
||||
}
|
||||
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
export function isExistingDirectory(input: { pathArg: string; cwd: string }): boolean {
|
||||
const resolvedPath = path.resolve(input.cwd, expandUserPath(input.pathArg));
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return statSync(resolvedPath).isDirectory();
|
||||
}
|
||||
|
||||
export function classifyInvocation(input: {
|
||||
argv: string[];
|
||||
knownCommands: ReadonlySet<string>;
|
||||
cwd: string;
|
||||
}): CliInvocation {
|
||||
const [firstArg] = input.argv;
|
||||
if (!firstArg) {
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
|
||||
if (firstArg.startsWith("-")) {
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
|
||||
if (input.knownCommands.has(firstArg)) {
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
|
||||
if (isExistingDirectory({ pathArg: firstArg, cwd: input.cwd })) {
|
||||
return {
|
||||
kind: "open-project",
|
||||
resolvedPath: path.resolve(input.cwd, expandUserPath(firstArg)),
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface LocalDaemonPidInfo {
|
||||
hostname?: string;
|
||||
uid?: number;
|
||||
listen?: string;
|
||||
desktopManaged?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalDaemonState {
|
||||
@@ -173,6 +174,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
: typeof parsed.sockPath === "string"
|
||||
? parsed.sockPath
|
||||
: undefined,
|
||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -34,6 +34,8 @@ interface DaemonStatus {
|
||||
daemonNode: string;
|
||||
cliNode: string;
|
||||
cliVersion: string;
|
||||
daemonVersion: string | null;
|
||||
desktopManaged: boolean;
|
||||
providers: ProviderBinaryStatus[];
|
||||
note?: string;
|
||||
}
|
||||
@@ -129,6 +131,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
{ key: "Daemon Node", value: status.daemonNode },
|
||||
{ key: "CLI Node", value: status.cliNode },
|
||||
{ key: "CLI", value: status.cliVersion },
|
||||
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
||||
];
|
||||
|
||||
if (status.runningAgents !== null && status.idleAgents !== null) {
|
||||
@@ -229,6 +232,7 @@ export async function runStatusCommand(
|
||||
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
|
||||
let runningAgents: number | null = null;
|
||||
let idleAgents: number | null = null;
|
||||
let daemonVersion: string | null = null;
|
||||
let note: string | undefined;
|
||||
|
||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||
@@ -240,6 +244,7 @@ export async function runStatusCommand(
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
connectedDaemon = "reachable";
|
||||
daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
@@ -308,6 +313,8 @@ export async function runStatusCommand(
|
||||
daemonNode,
|
||||
cliNode,
|
||||
cliVersion,
|
||||
daemonVersion,
|
||||
desktopManaged: state.pidInfo?.desktopManaged === true,
|
||||
providers,
|
||||
note,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function findDesktopApp(): string | null {
|
||||
if (process.platform === "darwin") {
|
||||
const candidates = [
|
||||
"/Applications/Paseo.app",
|
||||
path.join(homedir(), "Applications", "Paseo.app"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
const candidates = [
|
||||
"/usr/bin/Paseo",
|
||||
"/opt/Paseo/Paseo",
|
||||
path.join(homedir(), "Applications", "Paseo.AppImage"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = path.join(localAppData, "Programs", "Paseo", "Paseo.exe");
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanEnvForDesktopLaunch(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
// The CLI runs via ELECTRON_RUN_AS_NODE=1. On Linux/Windows the spawned
|
||||
// desktop process inherits the env directly, so we must strip it or the
|
||||
// desktop app would start as a bare Node process instead of Electron.
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
return env;
|
||||
}
|
||||
|
||||
function spawnDetached(command: string, args: string[]): void {
|
||||
spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: cleanEnvForDesktopLaunch(),
|
||||
}).unref();
|
||||
}
|
||||
|
||||
export async function openDesktopWithProject(projectPath: string): Promise<void> {
|
||||
try {
|
||||
if (process.env.PASEO_DESKTOP_CLI === "1") {
|
||||
throw new Error(
|
||||
"Cannot open a desktop project while running in desktop CLI passthrough mode.",
|
||||
);
|
||||
}
|
||||
|
||||
const desktopApp = findDesktopApp();
|
||||
if (!desktopApp) {
|
||||
throw new Error(
|
||||
"Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases",
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// -n forces a new instance even if the app is already running.
|
||||
// The new instance hits requestSingleInstanceLock(), fails, and relays
|
||||
// the argv to the first instance via the second-instance event.
|
||||
// -g keeps the terminal in the foreground (better CLI UX).
|
||||
// Without -n, macOS just activates the existing window and drops --args.
|
||||
spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
spawnDetached(desktopApp, [projectPath]);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
import { createCli } from "./cli.js";
|
||||
import { classifyInvocation } from "./classify.js";
|
||||
import { openDesktopWithProject } from "./commands/open.js";
|
||||
|
||||
const program = createCli();
|
||||
if (process.argv.length <= 2) {
|
||||
process.argv.push("onboard");
|
||||
const knownCommands = new Set(program.commands.map((command) => command.name()));
|
||||
|
||||
const invocation = classifyInvocation({
|
||||
argv: process.argv.slice(2),
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
switch (invocation.kind) {
|
||||
case "cli": {
|
||||
const argv = [...process.argv.slice(0, 2), ...invocation.argv];
|
||||
if (invocation.argv.length === 0) {
|
||||
argv.push("onboard");
|
||||
}
|
||||
program.parse(argv, { from: "node" });
|
||||
break;
|
||||
}
|
||||
case "open-project":
|
||||
await openDesktopWithProject(invocation.resolvedPath);
|
||||
break;
|
||||
}
|
||||
program.parse();
|
||||
|
||||
Reference in New Issue
Block a user