Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 4943b09764 fix(server): keep an agent's context usage across daemon restarts
The last usage report lived only on the in-memory agent. The stored agent
record never saved it, so after a restart both the stored snapshot and the
resumed agent reported no usage and the composer hid the context meter
until the next turn. Save it on the record and carry it into the stored
snapshot and the resumed session.
2026-09-30 03:05:07 +02:00
paseo-bot[bot] 5599f9e567 fix(cli): fail agent open for an agent the daemon does not have (#5727)
paseo agent open launched Desktop with whatever string it was given,
printed "opened" and exited 0, so a bad ID moved the user to a broken
"Agent not found" tab and the calling script saw success. Look the agent
up on the daemon first, the way inspect and mode do, and open Desktop on
the ID the daemon resolved.

Closes #5721

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 02:55:44 +02:00
paseo-bot[bot] 4e9a4582b7 fix(app): stop connecting to a host removed during its first probe (#5722)
Closes #4815

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 02:12:03 +02:00
Mohamed Boudra 3fea128382 docs(changelog): add 0.10.2 release notes
(cherry picked from commit c5b4b7bae3)
2026-09-30 01:16:24 +02:00
paseo-bot[bot]andBae Seongmin 53ee9cd993 fix(codex): settle declined commands and file edits as failed (#5717)
Codex completes a command or file change with status "declined" when its
approval is denied. The shared status normalizer did not know that word
and fell back to "running", so the denied command kept a spinner after
the turn ended.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-09-30 00:06:21 +02:00
16 changed files with 238 additions and 85 deletions
+10
View File
@@ -1,5 +1,15 @@
# 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,6 +4017,38 @@ 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,8 +677,15 @@ 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();
+30 -6
View File
@@ -1,5 +1,6 @@
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,
@@ -30,10 +31,21 @@ export function addOpenOptions(command: Command): Command {
.option("--server <server-id>", "Server ID (defaults to the local daemon)");
}
async function resolveServerId(options: CommandOptions): Promise<string> {
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> {
const explicitServerId = typeof options.server === "string" ? options.server.trim() : "";
if (explicitServerId) {
return explicitServerId;
return { serverId: explicitServerId, agentId };
}
const client = await connectToDaemon({ target: options.daemonTarget });
@@ -46,7 +58,19 @@ async function resolveServerId(options: CommandOptions): Promise<string> {
};
throw error;
}
return serverId;
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);
} finally {
await client.close().catch(() => {});
}
@@ -66,12 +90,12 @@ export async function runOpenCommand(
throw error;
}
const serverId = await resolveServerId(options);
await openDesktopWithAgent({ serverId, agentId });
const target = await resolveAgentTarget(agentId, options);
await openDesktopWithAgent(target);
return {
type: "single",
data: { agentId, serverId, status: "opened" },
data: { ...target, status: "opened" },
schema: openAgentSchema,
};
}
+52
View File
@@ -0,0 +1,52 @@
#!/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,9 +1,7 @@
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;
@@ -12,23 +10,6 @@ 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[] = [];
@@ -39,7 +20,20 @@ describe("editor target runtime", () => {
ELECTRON_RUN_AS_NODE: "1",
},
pathExists: (targetPath) => targetPath === "C:/Program Files/Editors & Tools/bin/code.cmd",
spawn: recordSpawns(records),
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;
},
});
const command = runtime.resolveCommand(["missing", "code"]);
@@ -63,32 +57,4 @@ 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,28 +56,10 @@ 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: {
searchDirectories: readonly string[];
env: NodeJS.ProcessEnv;
pathExists: (path: string) => boolean;
platform: NodeJS.Platform;
},
@@ -87,7 +69,10 @@ function resolveExecutable(
return command;
}
for (const directory of input.searchDirectories) {
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;
const candidate = `${directory}/${command}`;
if (input.platform !== "win32") {
if (input.pathExists(candidate)) return candidate;
@@ -155,12 +140,7 @@ export function createEditorTargetRuntime(
env,
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
resolveCommand: (commands) =>
resolveExecutable(commands, {
searchDirectories: commandSearchDirectories({ env, platform, homeDirectory }),
pathExists,
platform,
}),
resolveCommand: (commands) => resolveExecutable(commands, { env, 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", "dev.zed.Zed"] as const;
const COMMANDS = ["zed", "zeditor"] 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());
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
export const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
inputTokens: z.number().optional(),
cachedInputTokens: z.number().optional(),
outputTokens: z.number().optional(),
@@ -110,7 +110,11 @@ export async function ensureAgentLoaded(
handle,
buildConfigOverrides(record),
agentId,
{ ...extractTimestamps(record), attention: extractAttention(record) },
{
...extractTimestamps(record),
attention: extractAttention(record),
lastUsage: record.lastUsage,
},
record.archivedAt ? { purpose: "history" } : undefined,
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
@@ -1310,6 +1310,7 @@ export class AgentManager {
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
lastUsage?: AgentUsage;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
@@ -1342,6 +1343,7 @@ export class AgentManager {
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
lastUsage?: AgentUsage;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
@@ -1904,7 +1906,7 @@ export class AgentManager {
persistence: record.persistence ?? null,
historyPrimed: true,
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
lastUsage: undefined,
lastUsage: record.lastUsage,
lastError: record.lastError ?? undefined,
attention,
internal: record.internal,
@@ -90,6 +90,7 @@ 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,
@@ -243,6 +244,7 @@ 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 } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema, AgentUsageSchema } 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,6 +68,7 @@ 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,6 +656,29 @@ 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,6 +7,7 @@ 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"]);
@@ -0,0 +1,49 @@
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);
});