Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] e66773b7d0 fix(desktop): find Zed installed as a Flatpak on Linux
Flatpak puts app launchers in its exports/bin directories but does not
add them to PATH, so Zed installed from Flathub never appeared in the
Open in editor list. Search the user and system Flatpak export
directories after PATH on Linux, and look for Zed's Flathub launcher.

Closes #5458
2026-09-29 23:23:14 +02:00
16 changed files with 85 additions and 238 deletions
-10
View File
@@ -1,15 +1,5 @@
# Changelog
## 0.10.2 - 2026-09-30
### Fixed
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
## 0.10.1 - 2026-09-29
### Added
@@ -4017,38 +4017,6 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error");
store.syncHosts([]);
});
it("stops connecting to a host removed while its first probe is pending", async () => {
useHostRuntimeClock();
const firstProbe = createDeferred<void>();
let connectCalls = 0;
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
connectCalls += 1;
if (connectCalls === 1) await firstProbe.promise;
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_removed_host",
},
});
await store.upsertDirectConnection({ serverId: "srv_removed", endpoint: "lan:6767" });
await vi.waitFor(() => expect(connectCalls).toBe(1));
await store.removeHost("srv_removed");
firstProbe.resolve();
await vi.advanceTimersByTimeAsync(60_000);
expect(connectCalls).toBe(1);
expect(store.getSnapshot("srv_removed")).toBeNull();
expect(useSessionStore.getState().sessions["srv_removed"]).toBeUndefined();
});
it("saves and reconnects a rejected saved host after changing its password", async () => {
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
-7
View File
@@ -677,15 +677,8 @@ export class HostRuntimeController {
connectionId: options.initialConnection.connectionId,
existingClient: options.initialConnection.existingClient,
});
if (!this.started) {
return;
}
}
await this.runProbeCycleNow();
// stop() can run while startup is pending; a stopped controller never probes again.
if (!this.started) {
return;
}
if (options?.autoProbe !== false) {
this.probeIntervalHandle = setInterval(() => {
void this.runProbeCycleNow();
+6 -30
View File
@@ -1,6 +1,5 @@
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import type { AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link";
import { openDesktopWithAgent } from "../open.js";
import type {
CommandError,
@@ -31,21 +30,10 @@ export function addOpenOptions(command: Command): Command {
.option("--server <server-id>", "Server ID (defaults to the local daemon)");
}
function toOpenError(err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
return { code: "OPEN_FAILED", message: `Failed to open agent: ${message}` };
}
async function resolveAgentTarget(
agentId: string,
options: CommandOptions,
): Promise<AgentDeepLinkTarget> {
async function resolveServerId(options: CommandOptions): Promise<string> {
const explicitServerId = typeof options.server === "string" ? options.server.trim() : "";
if (explicitServerId) {
return { serverId: explicitServerId, agentId };
return explicitServerId;
}
const client = await connectToDaemon({ target: options.daemonTarget });
@@ -58,19 +46,7 @@ async function resolveAgentTarget(
};
throw error;
}
const fetchResult = await client.fetchAgent({ agentId });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentId}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
return { serverId, agentId: fetchResult.agent.id };
} catch (err) {
throw toOpenError(err);
return serverId;
} finally {
await client.close().catch(() => {});
}
@@ -90,12 +66,12 @@ export async function runOpenCommand(
throw error;
}
const target = await resolveAgentTarget(agentId, options);
await openDesktopWithAgent(target);
const serverId = await resolveServerId(options);
await openDesktopWithAgent({ serverId, agentId });
return {
type: "single",
data: { ...target, status: "opened" },
data: { agentId, serverId, status: "opened" },
schema: openAgentSchema,
};
}
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
console.log("=== Agent Open Command Tests ===\n");
if (process.platform !== "linux") {
console.log("Skipped: the fake Desktop app is a Linux AppImage path.");
process.exit(0);
}
const daemon = await startTestDaemon();
// runPaseoCli sets HOME to the daemon's home, where the CLI looks for
// ~/Applications/Paseo.AppImage on Linux. The fake app records its launch.
const launchRecord = join(daemon.paseoHome, "desktop-launches.txt");
const fakeDesktop = join(daemon.paseoHome, "Applications", "Paseo.AppImage");
await mkdir(join(daemon.paseoHome, "Applications"), { recursive: true });
await writeFile(fakeDesktop, `#!/bin/sh\necho "$@" >> "${launchRecord}"\n`);
await chmod(fakeDesktop, 0o755);
try {
{
console.log("Test 1: an unknown agent ID fails without opening Desktop");
const result = await runPaseoCli(daemon, [
"agent",
"open",
"does-not-exist",
"--host",
`127.0.0.1:${daemon.port}`,
"--json",
]);
assert.notStrictEqual(result.exitCode, 0, "open should fail for an unknown agent");
assert.match(JSON.parse(result.stderr).error.message, /Agent not found: does-not-exist/);
// Give a wrongly spawned detached app time to write its record.
await new Promise((resolve) => setTimeout(resolve, 500));
assert.ok(
!existsSync(launchRecord),
`Desktop should not open, but was launched with: ${existsSync(launchRecord) ? await readFile(launchRecord, "utf8") : ""}`,
);
console.log("✓ unknown agent ID fails without opening Desktop\n");
}
} finally {
await daemon.stop();
}
console.log("=== All agent open tests passed ===");
@@ -1,7 +1,9 @@
import type { SpawnOptions } from "node:child_process";
import { describe, expect, it } from "vitest";
import { listAvailableEditorTargets, openEditorTarget } from "./registry.js";
import { createEditorTargetRuntime } from "./runtime.js";
import { zedTarget } from "./targets/zed.js";
interface SpawnRecord {
command: string;
@@ -10,6 +12,23 @@ interface SpawnRecord {
unrefed: boolean;
}
function recordSpawns(records: SpawnRecord[]) {
return (command: string, args: string[], options: SpawnOptions) => {
const record = { command, args, options, unrefed: false };
records.push(record);
const child = {
once(event: "error" | "spawn", handler: (error?: Error) => void) {
if (event === "spawn") queueMicrotask(() => handler());
return child;
},
unref() {
record.unrefed = true;
},
};
return child;
};
}
describe("editor target runtime", () => {
it("resolves command aliases and safely launches Windows command scripts", async () => {
const records: SpawnRecord[] = [];
@@ -20,20 +39,7 @@ describe("editor target runtime", () => {
ELECTRON_RUN_AS_NODE: "1",
},
pathExists: (targetPath) => targetPath === "C:/Program Files/Editors & Tools/bin/code.cmd",
spawn: (command, args, options) => {
const record = { command, args, options, unrefed: false };
records.push(record);
const child = {
once(event: "error" | "spawn", handler: (error?: Error) => void) {
if (event === "spawn") queueMicrotask(() => handler());
return child;
},
unref() {
record.unrefed = true;
},
};
return child;
},
spawn: recordSpawns(records),
});
const command = runtime.resolveCommand(["missing", "code"]);
@@ -57,4 +63,32 @@ describe("editor target runtime", () => {
},
]);
});
it.each([
["system", "/var/lib/flatpak/exports/bin/dev.zed.Zed"],
["user", "/home/dev/.local/share/flatpak/exports/bin/dev.zed.Zed"],
])("finds and opens Zed from a %s Flatpak install on Linux", async (_scope, flatpakExport) => {
const records: SpawnRecord[] = [];
const existingPaths = new Set([flatpakExport, "/repo", "/repo/src/app.ts"]);
const runtime = createEditorTargetRuntime({
platform: "linux",
env: { PATH: "/usr/local/bin:/usr/bin:/bin" },
homeDirectory: "/home/dev",
pathExists: (targetPath) => existingPaths.has(targetPath),
spawn: recordSpawns(records),
loadIcon: async (fileName) => ({ kind: "image", dataUrl: fileName }),
});
const targets = await listAvailableEditorTargets(runtime, [zedTarget]);
expect(targets.map((target) => target.id)).toEqual(["zed"]);
await openEditorTarget(
{ editorId: "zed", workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 3 },
runtime,
[zedTarget],
);
expect(records.map(({ command, args }) => ({ command, args }))).toEqual([
{ command: flatpakExport, args: ["/repo", "/repo/src/app.ts:3"] },
]);
});
});
@@ -56,10 +56,28 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
// Flatpak exports app launchers here, but does not put these directories on PATH.
function flatpakExportDirectories(env: NodeJS.ProcessEnv, homeDirectory: string): string[] {
const userDataDirectory = env.XDG_DATA_HOME || `${homeDirectory}/.local/share`;
return [`${userDataDirectory}/flatpak/exports/bin`, "/var/lib/flatpak/exports/bin"];
}
function commandSearchDirectories(input: {
env: NodeJS.ProcessEnv;
platform: NodeJS.Platform;
homeDirectory: string;
}): string[] {
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? "";
const pathDelimiter = input.platform === "win32" ? ";" : ":";
const directories = pathValue.split(pathDelimiter).filter(Boolean);
if (input.platform !== "linux") return directories;
return [...directories, ...flatpakExportDirectories(input.env, input.homeDirectory)];
}
function resolveExecutable(
commands: readonly string[],
input: {
env: NodeJS.ProcessEnv;
searchDirectories: readonly string[];
pathExists: (path: string) => boolean;
platform: NodeJS.Platform;
},
@@ -69,10 +87,7 @@ function resolveExecutable(
return command;
}
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? "";
const pathDelimiter = input.platform === "win32" ? ";" : ":";
for (const directory of pathValue.split(pathDelimiter)) {
if (!directory) continue;
for (const directory of input.searchDirectories) {
const candidate = `${directory}/${command}`;
if (input.platform !== "win32") {
if (input.pathExists(candidate)) return candidate;
@@ -140,7 +155,12 @@ export function createEditorTargetRuntime(
env,
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
resolveCommand: (commands) =>
resolveExecutable(commands, {
searchDirectories: commandSearchDirectories({ env, platform, homeDirectory }),
pathExists,
platform,
}),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
const launchCommand = commandScript ? escapeWindowsCmdValue(command) : command;
@@ -1,6 +1,6 @@
import type { EditorTarget, EditorTargetLaunchInput } from "../target.js";
const COMMANDS = ["zed", "zeditor"] as const;
const COMMANDS = ["zed", "zeditor", "dev.zed.Zed"] as const;
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
+1 -1
View File
@@ -381,7 +381,7 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z
})
.catchall(z.boolean());
export const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
inputTokens: z.number().optional(),
cachedInputTokens: z.number().optional(),
outputTokens: z.number().optional(),
@@ -110,11 +110,7 @@ export async function ensureAgentLoaded(
handle,
buildConfigOverrides(record),
agentId,
{
...extractTimestamps(record),
attention: extractAttention(record),
lastUsage: record.lastUsage,
},
{ ...extractTimestamps(record), attention: extractAttention(record) },
record.archivedAt ? { purpose: "history" } : undefined,
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
@@ -1310,7 +1310,6 @@ export class AgentManager {
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
lastUsage?: AgentUsage;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
@@ -1343,7 +1342,6 @@ export class AgentManager {
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
lastUsage?: AgentUsage;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
@@ -1906,7 +1904,7 @@ export class AgentManager {
persistence: record.persistence ?? null,
historyPrimed: true,
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
lastUsage: record.lastUsage,
lastUsage: undefined,
lastError: record.lastError ?? undefined,
attention,
internal: record.internal,
@@ -90,7 +90,6 @@ export function toStoredAgentRecord(
runtimeInfo,
features: normalizeFeatures(agent.features),
persistence,
lastUsage: sanitizeUsage(agent.lastUsage),
lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention,
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
@@ -244,7 +243,6 @@ export function buildStoredAgentPayload(
pendingPermissions: [],
persistence,
title: record.title ?? null,
...(record.lastUsage ? { lastUsage: record.lastUsage } : {}),
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
@@ -4,7 +4,7 @@ import { z } from "zod";
import type { Logger } from "pino";
import { writeJsonFileAtomic } from "../atomic-file.js";
import { AgentFeatureSchema, AgentStatusSchema, AgentUsageSchema } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
@@ -68,7 +68,6 @@ const STORED_AGENT_SCHEMA = z.object({
.optional(),
features: z.array(AgentFeatureSchema).optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
lastUsage: AgentUsageSchema.optional(),
lastError: z.string().nullable().optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
@@ -656,29 +656,6 @@ describe("codex tool-call mapper", () => {
});
});
it.each([
{
type: "commandExecution",
id: "codex-declined-command",
status: "declined",
command: "printf ok > /outside/permission.txt",
cwd: "/tmp/repo",
aggregatedOutput: null,
exitCode: null,
},
{
type: "fileChange",
id: "codex-declined-patch",
status: "declined",
changes: [],
},
])("settles a declined $type as failed instead of running", (threadItem) => {
const item = expectMapped(mapCodexToolCallFromThreadItem(threadItem));
expect(item.callId).toBe(threadItem.id);
expect(item.status).toBe("failed");
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = expectMapped(
mapCodexToolCallEnvelope({
@@ -7,7 +7,6 @@ const FAILED_STATUS_VOCAB = new Set([
"errored",
"rejected",
"denied",
"declined",
]);
const CANCELED_STATUS_VOCAB = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
const COMPLETED_STATUS_VOCAB = new Set(["completed", "complete", "done", "success", "succeeded"]);
@@ -1,49 +0,0 @@
import { afterEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
describe("daemon E2E - usage after restart", () => {
let ctx: DaemonTestContext | null = null;
const dirs: string[] = [];
afterEach(async () => {
await ctx?.cleanup();
ctx = null;
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
test("a finished agent reports its last usage after the daemon restarts", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "usage-restart-cwd-"));
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "usage-restart-home-"));
dirs.push(cwd, paseoHomeRoot);
ctx = await createDaemonTestContext({ paseoHomeRoot, cleanup: false });
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Usage restart agent",
modeId: "full-access",
});
await ctx.client.sendMessage(agent.id, "Say 'done' and nothing else");
const finished = await ctx.client.waitForFinish(agent.id, 5_000);
expect(finished.status).toBe("idle");
const usageBeforeRestart = finished.final?.lastUsage;
expect(usageBeforeRestart).toBeDefined();
await ctx.cleanup();
ctx = await createDaemonTestContext({ paseoHomeRoot, cleanup: false });
const stored = await ctx.client.fetchAgent(agent.id);
expect(stored?.agent.lastUsage).toEqual(usageBeforeRestart);
await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
const reopened = await ctx.client.fetchAgent(agent.id);
expect(reopened?.agent.status).toBe("idle");
expect(reopened?.agent.lastUsage).toEqual(usageBeforeRestart);
}, 30_000);
});