Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 7e29171d09 Add failing repro: a timed-out background fetch leaves a partial pack (#3335) 2026-10-07 21:28:26 +02:00
paseo-bot[bot] 1ae979d3d0 Subagents that finish while the app is disconnected no longer stay "working" (#6316)
* fix(app): catch up provider subagents after the update feed reconnects

Subagent updates are not replayed across a reconnect, so a child that
finished while the app was disconnected stayed "working" until one
subagent was opened and its parent was listed again. Re-list every
parent the app holds once the update feed resubscribes.

Closes #6109

* fix(app): resync a subagent parent even when its first list failed

A parent's subagent track lists the parent once on mount. When that list
failed because the app was disconnected, the parent was never tracked,
so the reconnect resync skipped it and children that later arrived via
live updates stayed stale after the next drop. Track the parent when its
list is requested instead of when it succeeds.

* test(website): expect the plugin directory links now that they are on

1b88f1612 turned PLUGINS_LINKED on, but the e2e test still asserted the
directory was unlinked, failing the typecheck job on main and every PR.
Assert the navigation, footer, and Browse plugins links point at /plugins.
The header check now targets the navigation landmark the homepage renders;
the banner locator matched nothing, so it only passed while asserting zero.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 18:30:20 +02:00
paseo-bot[bot] d46164ce0d fix(claude): keep long-running tool calls out of the subagents track (#6308)
Claude Code sends a tool_progress heartbeat every 30 s while a tool is
still running, with that tool's own id as parent_tool_use_id. The Claude
adapter routed any frame with a parent id to the sidechain tracker, so
a main-thread call that ran past 30 s (MCP, WebFetch, WebSearch) became
a "Claude subagent" row with a child action per heartbeat that never
completes, and its card was relabeled Task.

A heartbeat now names no sidechain, so it never reaches subagent
routing.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 17:17:17 +02:00
Mohamed Boudra a591320409 docs(changelog): announce the plugin registry in 0.11.0 2026-10-07 16:38:38 +02:00
paseo-bot[bot] ee6deda079 Show a Claude agent's history when its transcript is under another project folder (#6301)
* fix(claude): load history from a transcript under another project folder

Claude Code resumes a session by id from any working directory and keeps
writing to the transcript in the project folder where the session started.
When the agent's cwd encodes to a different folder (a moved project, a
migrated daemon, a Windows binary under WSL), the agent kept working but
reopened with an empty timeline. Look the transcript up by session id
across the config dir's project folders when it is not where cwd points.

Refs #4540

* fix(claude): report a failed project folder listing instead of an absent transcript

Take the transcript lookup's inputs as one object, and rethrow read errors other than a missing projects folder so the history load logs them as a failure.

* test(claude): skip the unreadable projects folder case when running as root

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 16:09:34 +02:00
paseo-ai[bot] 0555c877d0 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-07 13:49:31 +00:00
Mohamed Boudra b6454d7ea4 fix(website): show paseo plugin add as the registry install command 2026-10-07 15:33:25 +02:00
Mohamed Boudra 1b88f16124 feat(website): link the public plugin registry 2026-10-07 15:33:25 +02:00
Mohamed Boudra 1fae8e89f7 fix(website): mute footer column titles by shade instead of weight 2026-10-07 15:33:25 +02:00
16 changed files with 566 additions and 50 deletions
+11 -4
View File
@@ -2,6 +2,10 @@
## 0.11.0 - 2026-10-07
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
@@ -19,18 +23,21 @@
- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
### Plugins
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
### Changed
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
+1 -1
View File
@@ -1 +1 @@
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
sha256-AKY6OMZgHeyn/To/2zsmtfb5C4fKniUp8gvpEYaR7/o=
@@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
});
const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
const unsubProviderSubagentUpdate = feeds.subscribe({
snapshot: () => {
void resyncProviderSubagents(client, serverId);
},
update: (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
},
});
const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => {
@@ -1,8 +1,11 @@
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test } from "vitest";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
observeProviderSubagentTimeline,
providerSubagentKey,
refreshProviderSubagents,
resyncProviderSubagents,
useProviderSubagentStore,
} from "./provider-store";
@@ -15,6 +18,7 @@ afterEach(() => {
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
});
});
@@ -727,3 +731,96 @@ describe("projected child history", () => {
expect(current().lastSeq).toBe(2000);
});
});
describe("resyncing after the update feed resubscribes", () => {
function child(
id: string,
parentAgentId: string,
status: ProviderSubagentDescriptorPayload["status"],
): ProviderSubagentDescriptorPayload {
return {
id,
parentAgentId,
provider: "claude",
title: "Explore",
description: null,
status,
createdAt: "2026-10-07T10:00:00.000Z",
updatedAt: "2026-10-07T10:00:00.000Z",
toolCallId: id,
};
}
function fakeDaemon(lists: Record<string, ProviderSubagentDescriptorPayload[]>) {
const requested: string[] = [];
return {
requested,
lists,
client: {
listProviderSubagents: async (parentAgentId: string) => {
requested.push(parentAgentId);
return {
requestId: "list",
parentAgentId,
subagents: lists[parentAgentId] ?? [],
error: null,
};
},
},
};
}
const status = (parentAgentId: string, subagentId: string) =>
useProviderSubagentStore
.getState()
.descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status;
test("catches up on children that finished or started while updates were not delivered", async () => {
const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] });
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
expect(status(PARENT_ID, "child-1")).toBe("running");
// The connection dropped: the daemon finished child-1 and ran child-2 without telling us.
daemon.lists[PARENT_ID] = [
child("child-1", PARENT_ID, "completed"),
child("child-2", PARENT_ID, "completed"),
];
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(status(PARENT_ID, "child-1")).toBe("completed");
expect(status(PARENT_ID, "child-2")).toBe("completed");
});
test("catches up on a parent whose first list failed while disconnected", async () => {
const daemon = fakeDaemon({});
const disconnected = {
listProviderSubagents: async () => {
throw new DaemonConnectionError("Transport not connected");
},
};
await expect(
refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID),
).rejects.toBeInstanceOf(DaemonConnectionError);
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: child("child-1", PARENT_ID, "running"),
});
// The connection dropped again: the daemon finished child-1 without telling us.
daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")];
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(status(PARENT_ID, "child-1")).toBe("completed");
});
test("lists again only the parents this server was showing", async () => {
const daemon = fakeDaemon({});
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
await refreshProviderSubagents(daemon.client, "server-2", "other-parent");
daemon.requested.length = 0;
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(daemon.requested).toEqual([PARENT_ID]);
});
});
@@ -28,6 +28,9 @@ interface ProviderSubagentState {
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
timelines: Map<string, ProviderSubagentTimelineState>;
hiddenFromTrack: Set<string>;
/** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */
trackedParents: Set<string>;
trackParent(serverId: string, parentAgentId: string): void;
hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void;
replaceList(
serverId: string,
@@ -84,6 +87,7 @@ export function refreshProviderSubagents(
const pending = clientRequests.get(requestKey);
if (pending) return pending;
useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
const request = client
.listProviderSubagents(parentAgentId)
.then((payload) => {
@@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string {
return `${serverId}\0${parentAgentId}\0`;
}
/**
* Updates are not replayed across a reconnect, so a child that finished or started while the
* update feed was down stays stale until its parent is listed again. Call this once the feed has
* resubscribed, so every update after the list is delivered.
*/
export async function resyncProviderSubagents(
client: ProviderSubagentListClient,
serverId: string,
): Promise<void> {
const serverPrefix = `${serverId}\0`;
const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents]
.filter((key) => key.startsWith(serverPrefix))
.map((key) => key.slice(serverPrefix.length, -1));
await Promise.all(
parentAgentIds.map((parentAgentId) =>
// A parent that was archived meanwhile has nothing left to catch up.
refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined),
),
);
}
const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
tail: [],
head: [],
@@ -147,6 +172,14 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
trackParent(serverId, parentAgentId) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
if (state.trackedParents.has(prefix)) return state;
return { trackedParents: new Set(state.trackedParents).add(prefix) };
});
},
hideFromTrack(serverId, parentAgentId, subagentIds) {
set((state) => {
const hiddenFromTrack = new Set(state.hiddenFromTrack);
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
@@ -90,4 +90,29 @@ describe("ClaudeAgentSession persisted history load", () => {
}),
);
});
// chmod does not remove read access on Windows, or for root.
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"logs a warning with the error when Claude's project folders cannot be listed",
async () => {
// The transcript is not under cwd's folder, so the lookup lists the other project folders.
mkdirSync(path.join(configDir, "projects"), { recursive: true });
chmodSync(path.join(configDir, "projects"), 0o000);
try {
await resume();
} finally {
chmodSync(path.join(configDir, "projects"), 0o755);
}
expect(records).toContainEqual(
expect.objectContaining({
level: pino.levels.values.warn,
msg: "Failed to load Claude history from transcript",
sessionId: SESSION_ID,
err: expect.objectContaining({ message: expect.stringContaining("EACCES") }),
}),
);
},
);
});
@@ -587,4 +587,75 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(latest.detail.log).toContain("[Read] file-6.md");
expect(latest.detail.log).toContain("[Read] file-205.md");
});
test("keeps a long main-thread tool call out of the subagents track", async () => {
// Claude Code sends a heartbeat every 30 s while a tool runs, with the tool's own id as
// parent_tool_use_id. Frames captured from Claude Code 2.1.291 for a 40 s MCP call.
queryFactory.mockImplementation(() =>
buildQueryMock([
{
type: "system",
subtype: "init",
session_id: "heartbeat-session",
permissionMode: "bypassPermissions",
model: "opus",
},
{
type: "assistant",
parent_tool_use_id: null,
message: {
id: "msg-heartbeat-1",
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_slow", name: "mcp__slow__slow_check", input: {} },
],
},
},
{
type: "tool_progress",
tool_use_id: "toolu_slow-heartbeat-0",
tool_name: "mcp__slow__slow_check",
parent_tool_use_id: "toolu_slow",
elapsed_time_seconds: 30,
heartbeat: true,
},
{
type: "user",
parent_tool_use_id: null,
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_slow",
content: [{ type: "text", text: "SLOW_CHECK_OK" }],
is_error: false,
},
],
},
},
{
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
},
]),
);
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const events = await collectUntilTerminal(streamSession(session, "run the slow check"));
await session.close();
expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]);
const toolCallNames = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item.name] : [],
);
expect(toolCallNames).toContain("mcp__slow__slow_check");
expect(toolCallNames).not.toContain("Task");
});
});
@@ -84,7 +84,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -1916,6 +1916,11 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null {
if (!("parent_tool_use_id" in message)) {
return null;
}
// Claude Code's liveness tick for a tool still running after 30 s names that tool as its
// parent, so a long main-thread call would otherwise read as a subagent.
if (message.type === "tool_progress" && message.heartbeat === true) {
return null;
}
const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id;
return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null;
}
@@ -5216,26 +5221,11 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
const configDir = claudeConfigDir(this.harnessEnvironment);
const candidates = [cwd];
try {
const realCwd = fs.realpathSync(cwd);
if (realCwd !== cwd) {
candidates.push(realCwd);
}
} catch {
// Fall back to the configured cwd when the path has already disappeared.
}
for (const candidate of candidates) {
const historyPath = path.join(
claudeProjectDirSync(candidate, { configDir }),
`${sessionId}.jsonl`,
);
if (fs.existsSync(historyPath)) {
return historyPath;
}
}
return path.join(claudeProjectDirSync(cwd, { configDir }), `${sessionId}.jsonl`);
return claudeTranscriptPathSync({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
});
}
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
@@ -1,4 +1,4 @@
import { realpathSync } from "node:fs";
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -30,6 +30,46 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
return join(projectsRoot, encode(canonical));
}
// Claude Code resumes a session by id from any working directory and keeps appending to the
// transcript in the project folder where the session started. That folder differs from the
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
// transcript exists anywhere.
export function claudeTranscriptPathSync(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): string {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = readdirSync(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
}
throw error;
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (existsSync(candidate)) {
return candidate;
}
}
return expected;
}
function isMissingDirectoryError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR";
}
async function canonicalize(input: string): Promise<string> {
try {
return normalizeProjectPath(await realpath(input));
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
// Claude Code resumes a session by id from any working directory and keeps writing to the
// transcript under the project folder it started in. The agent's cwd can name a different
// folder: a project that moved, a daemon migrated between machines or containers, or Claude
// Code running under WSL interop and seeing the cwd as a Windows path.
// The test daemon fakes the built-in providers; a provider that extends `claude` runs the real one.
const PROVIDER_ID = "claude-real";
const SESSION_ID = "6f0c5d8e-4c3a-4f7b-9a51-2d8e1b7c9f10";
function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string {
return entries
.filter(
(
entry,
): entry is {
item: { type: "user_message" | "assistant_message"; text: string };
} => entry.item.type === "user_message" || entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
}
describe("daemon E2E - Claude history stored under another project folder", () => {
let tempRoot: string;
let paseoHomeRoot: string;
let configDir: string;
let transcriptCwd: string;
let agentCwd: string;
let daemon: TestPaseoDaemon | undefined;
let client: DaemonClient | undefined;
beforeEach(() => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-history-other-project-"));
paseoHomeRoot = path.join(tempRoot, "paseo-home");
configDir = path.join(tempRoot, "claude-config");
transcriptCwd = path.join(tempRoot, "where-claude-started");
agentCwd = path.join(tempRoot, "agent-cwd");
mkdirSync(paseoHomeRoot, { recursive: true });
mkdirSync(agentCwd, { recursive: true });
const projectDir = claudeProjectDirSync(transcriptCwd, { configDir });
mkdirSync(projectDir, { recursive: true });
writeFileSync(
path.join(projectDir, `${SESSION_ID}.jsonl`),
[
{
type: "user",
uuid: "user-uuid-1",
sessionId: SESSION_ID,
cwd: transcriptCwd,
message: { role: "user", content: "hello from the original folder" },
},
{
type: "assistant",
sessionId: SESSION_ID,
cwd: transcriptCwd,
message: {
role: "assistant",
content: "reply from the original folder",
},
},
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join(""),
"utf8",
);
});
afterEach(async () => {
await client?.close().catch(() => undefined);
await daemon?.close().catch(() => undefined);
client = undefined;
daemon = undefined;
rmSync(tempRoot, { recursive: true, force: true });
}, 60_000);
async function startDaemon(): Promise<DaemonClient> {
daemon = await createTestPaseoDaemon({
paseoHomeRoot,
cleanup: false,
providerOverrides: {
[PROVIDER_ID]: {
extends: "claude",
label: "Claude",
env: { CLAUDE_CONFIG_DIR: configDir },
},
},
});
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: {} });
return client;
}
async function stopDaemon(): Promise<void> {
await client?.close();
await daemon?.close();
client = undefined;
daemon = undefined;
}
test("an agent's timeline survives a daemon restart", async () => {
const firstClient = await startDaemon();
const agent = await firstClient.importAgent({
provider: PROVIDER_ID,
sessionId: SESSION_ID,
cwd: agentCwd,
});
await stopDaemon();
const restartedClient = await startDaemon();
const timeline = await restartedClient.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const text = timelineText(timeline.entries);
expect(text).toContain("hello from the original folder");
expect(text).toContain("reply from the original folder");
}, 60_000);
});
@@ -0,0 +1,120 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { afterAll, beforeAll, expect, test } from "vitest";
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
import { fetchWorkspaceGitRemote } from "./workspace-git-fetch.js";
// Repro for #3335: a background fetch that outlives its timeout is SIGKILLed,
// and every killed attempt leaves a partial `tmp_pack_*` behind. The daemon
// retries every 180s, so a fetch that needs longer than the timeout grows
// `.git/objects/pack` without bound.
const FETCH_TIMEOUT_MS = 300;
const cleanupPaths: string[] = [];
let checkout: string;
function git(cwd: string, args: string[]): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" },
}).trim();
}
beforeAll(() => {
const root = mkdtempSync(join(tmpdir(), "paseo-fetch-timeout-"));
cleanupPaths.push(root);
const upstream = join(root, "upstream.git");
const seed = join(root, "seed");
checkout = join(root, "checkout");
git(root, ["init", "-q", "--bare", "-b", "main", upstream]);
git(root, ["init", "-q", "-b", "main", seed]);
git(seed, [
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
]);
git(seed, ["push", "-q", upstream, "main"]);
git(root, ["clone", "-q", "--no-local", upstream, checkout]);
// Advance upstream with incompressible content so the next fetch takes
// longer than FETCH_TIMEOUT_MS to transfer. More than git's default
// `fetch.unpackLimit` (100 objects) so the fetch keeps a pack, like any
// large fetch does, instead of exploding into loose objects.
for (let i = 0; i < 200; i += 1) {
writeFileSync(join(seed, `blob-${i}.bin`), randomBytes(1024 * 1024));
}
git(seed, ["add", "."]);
git(seed, [
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"-m",
"large",
]);
git(seed, ["push", "-q", upstream, "main"]);
}, 120_000);
afterAll(() => {
for (const path of cleanupPaths.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
function packDirectory(): { tmpPacks: string[] } {
const entries = readdirSync(join(checkout, ".git", "objects", "pack"));
return { tmpPacks: entries.filter((entry) => entry.startsWith("tmp_pack_")) };
}
// The daemon's runner, with the fetch budget shortened from 120s so a local
// fetch outlives it the way a large repository's fetch outlives 120s.
const runWithShortFetchTimeout: RunGitCommand = (args, options) =>
runGitCommand(
args,
args[0] === "fetch" ? { ...options, timeout: FETCH_TIMEOUT_MS } : options
);
test("a background fetch that times out leaves no partial pack behind", async () => {
const before = packDirectory();
const observations: { error: string | null; tmpPacks: number }[] = [];
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await fetchWorkspaceGitRemote(
checkout,
{ onRefSnapshot() {} },
runWithShortFetchTimeout
);
const after = packDirectory();
observations.push({
error: result.error instanceof Error ? result.error.message : null,
tmpPacks: after.tmpPacks.length,
});
}
expect(observations).toEqual([
{
error: expect.stringContaining("timed out"),
tmpPacks: before.tmpPacks.length,
},
{
error: expect.stringContaining("timed out"),
tmpPacks: before.tmpPacks.length,
},
{
error: expect.stringContaining("timed out"),
tmpPacks: before.tmpPacks.length,
},
]);
}, 60_000);
+8 -9
View File
@@ -41,14 +41,14 @@ test("browses from the directory into a category, a plugin, and its author", asy
await page.getByRole("link", { name: /Fresh Worktrees/ }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/);
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1);
await expect(
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Copy to clipboard" }).click();
await expect
.poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied))
.toBe("paseo plugin install omercnet/fresh-worktrees");
.toBe("paseo plugin add omercnet/fresh-worktrees");
await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute(
"href",
"/plugins/category/git",
@@ -242,7 +242,7 @@ test.describe("search engine visits without JavaScript", () => {
expect(response?.status()).toBe(200);
expect(response?.headers()["cache-control"]).toBe("private, no-store");
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toBeVisible();
await expect(
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible();
@@ -388,19 +388,18 @@ async function expectPageMetadata(page: Page, title: string, path: string) {
);
}
test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => {
test("links the directory from the site navigation", async ({ page }) => {
await page.goto("/");
await expect(
page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }),
).toHaveCount(0);
page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }),
).toHaveAttribute("href", "/plugins");
await expect(
page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }),
).toHaveCount(0);
).toHaveAttribute("href", "/plugins");
await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute(
"href",
"https://paseo.cafe",
"/plugins",
);
await expect(page.locator('a[href="/plugins"]')).toHaveCount(0);
const response = await page.goto("/plugins");
expect(response?.status()).toBe(200);
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
@@ -19,7 +19,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
<footer className={`${widthClasses} mx-auto mt-12 md:mt-16`}>
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm">
<div className="space-y-3">
<p className="text-white/60 font-medium">Product</p>
<p className="text-extra-muted-foreground">Product</p>
<div className="space-y-2">
<a
href="/blog"
@@ -80,7 +80,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Agents</p>
<p className="text-extra-muted-foreground">Agents</p>
<div className="space-y-2">
<a
href="/claude-code"
@@ -121,7 +121,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Alternatives</p>
<p className="text-extra-muted-foreground">Alternatives</p>
<div className="space-y-2">
{alternatives.map((page) => (
<a
@@ -135,7 +135,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Community</p>
<p className="text-extra-muted-foreground">Community</p>
<div className="space-y-2">
<a
href="https://discord.gg/jz8T2uahpH"
@@ -164,7 +164,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Download</p>
<p className="text-extra-muted-foreground">Download</p>
<div className="space-y-2">
<a
href={appStoreUrl}
+1 -2
View File
@@ -1,2 +1 @@
// Keep navigation on paseo.cafe until the coordinated directory announcement.
export const PLUGINS_LINKED = false;
export const PLUGINS_LINKED = true;
@@ -263,7 +263,7 @@ describe("plugin registry", () => {
);
});
it("offers a registry install command", () => {
expect(installCommand(plugin)).toBe("paseo plugin install acme/example");
expect(installCommand(plugin)).toBe("paseo plugin add acme/example");
expect(formatInstalls(1250)).toBe("1.3k");
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] {
return plugins.filter((plugin) => pluginOwner(plugin) === owner);
}
export function installCommand(plugin: Plugin): string {
return `paseo plugin install ${plugin.id}`;
return `paseo plugin add ${plugin.id}`;
}
export function pluginVersion(plugin: Plugin): string {
return plugin.artifact.kind === "npm"