Compare commits

...
9 Commits
Author SHA1 Message Date
paseo-bot[bot] c32d82fd40 fix(claude): pass only transcript entries to the rewind session store
The session store parsed each transcript line and cast it to a session
entry without checking it. Keep only JSON objects with a string type, and
cover transcripts with non-entry lines in the real-SDK rewind test.
2026-10-07 19:16:56 +02:00
paseo-bot[bot] 499665efbb fix(claude): rewind a session that lives in the provider's CLAUDE_CONFIG_DIR
Rewinding the conversation forks the Claude session through the SDK's
forkSession, which looks for the transcript under the daemon's own
CLAUDE_CONFIG_DIR or ~/.claude. A provider whose env sets CLAUDE_CONFIG_DIR
has its transcripts elsewhere, so every rewind failed with
"Session <id> not found".

Fork through a session store rooted at the folder of the transcript the
agent already resolves with the provider's config dir, so the SDK reads
the session where Claude Code wrote it and writes the fork beside it.

Closes #6321
2026-10-07 18:44:46 +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
15 changed files with 504 additions and 47 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=
@@ -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;
}
@@ -2825,9 +2830,11 @@ class ClaudeAgentSession implements AgentSession {
this.startFreshConversationSession();
return;
}
const currentSessionId = this.claudeSessionId;
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: this.claudeSessionId,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => {
@@ -5216,26 +5223,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));
@@ -1,10 +1,15 @@
import { describe, expect, test } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import {
revertClaudeConversation,
revertClaudeConversationAndFiles,
revertClaudeFiles,
realClaudeRewindSdk,
} from "./rewind.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
@@ -16,6 +21,7 @@ describe("Claude rewind", () => {
await revertClaudeConversation({
sdk: claude,
sessionId,
transcriptPath: "/claude/projects/repo/original-session.jsonl",
messageId: "user-message-1",
setSessionId: (nextSessionId) => {
sessionId = nextSessionId;
@@ -33,6 +39,7 @@ describe("Claude rewind", () => {
await revertClaudeConversation({
sdk: claude,
sessionId,
transcriptPath: "/claude/projects/repo/original-session.jsonl",
messageId: "timeline-message-1",
resolveMessageId: () => "claude-jsonl-message-1",
setSessionId: (nextSessionId) => {
@@ -76,6 +83,7 @@ describe("Claude rewind", () => {
sdk: claude,
query: claude.createQuery() as Query,
sessionId,
transcriptPath: "/claude/projects/repo/original-session.jsonl",
messageId: "user-message-1",
setSessionId: (nextSessionId) => {
sessionId = nextSessionId;
@@ -87,3 +95,126 @@ describe("Claude rewind", () => {
expect(sessionId).toBe("forked-before-rehydrate");
});
});
interface TranscriptLine {
type: "user" | "assistant";
uuid: string;
parentUuid: string | null;
text: string;
}
function writeTranscript(
transcriptPath: string,
sessionId: string,
lines: Array<TranscriptLine | string>,
): void {
const entries = lines.map((line) => {
if (typeof line === "string") return line;
const { type, uuid, parentUuid, text } = line;
return JSON.stringify({
type,
uuid,
parentUuid,
sessionId,
cwd: "/repo",
timestamp: new Date().toISOString(),
message: { role: type, content: text },
});
});
writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8");
}
function readMessageTexts(transcriptPath: string): string[] {
const entries = readFileSync(transcriptPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { message?: { content: string } });
return entries.flatMap((entry) => (entry.message ? [entry.message.content] : []));
}
describe("Claude rewind against the Claude SDK", () => {
let tempRoot: string | null = null;
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
afterEach(() => {
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
tempRoot = null;
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
});
test("forks a session that lives in a provider's own CLAUDE_CONFIG_DIR", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
// The provider sets CLAUDE_CONFIG_DIR for Claude Code; the daemon's own environment does not.
delete process.env.CLAUDE_CONFIG_DIR;
const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const sessionId = randomUUID();
const firstUser = randomUUID();
const firstReply = randomUUID();
const secondUser = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
writeTranscript(transcriptPath, sessionId, [
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
{ type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" },
{ type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" },
]);
let currentSessionId = sessionId;
await revertClaudeConversation({
sdk: realClaudeRewindSdk,
sessionId,
transcriptPath,
messageId: firstReply,
setSessionId: (nextSessionId) => {
currentSessionId = nextSessionId;
},
});
expect(currentSessionId).not.toBe(sessionId);
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
"first",
"first reply",
]);
});
test("skips transcript lines that are not entries when forking", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
delete process.env.CLAUDE_CONFIG_DIR;
const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const sessionId = randomUUID();
const firstUser = randomUUID();
const firstReply = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
writeTranscript(transcriptPath, sessionId, [
"null",
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
"[1, 2]",
'"text"',
"{}",
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
{ type: "user", uuid: randomUUID(), parentUuid: firstReply, text: "second" },
]);
let currentSessionId = sessionId;
await revertClaudeConversation({
sdk: realClaudeRewindSdk,
sessionId,
transcriptPath,
messageId: firstReply,
setSessionId: (nextSessionId) => {
currentSessionId = nextSessionId;
},
});
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
"first",
"first reply",
]);
});
});
@@ -1,29 +1,90 @@
import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
import { appendFile, readFile } from "node:fs/promises";
import path from "node:path";
import {
forkSession as claudeForkSession,
type Query,
type SessionStore,
type SessionStoreEntry,
} from "@anthropic-ai/claude-agent-sdk";
export interface ClaudeRewindSdk {
forkSession(
sessionId: string,
options: { upToMessageId: string },
options: { upToMessageId: string; transcriptPath: string },
): Promise<{ sessionId: string }>;
}
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
// provider can point Claude Code at another one. Forking through a store rooted at the
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
export const realClaudeRewindSdk: ClaudeRewindSdk = {
forkSession: claudeForkSession,
forkSession: (sessionId, options) =>
claudeForkSession(sessionId, {
upToMessageId: options.upToMessageId,
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
}),
};
function projectFolderSessionStore(projectFolder: string): SessionStore {
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
return {
async load({ sessionId }) {
let text: string;
try {
text = await readFile(transcriptPath(sessionId), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
return parseTranscriptEntries(text);
},
async append({ sessionId }, entries) {
const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
await appendFile(transcriptPath(sessionId), lines, "utf8");
},
};
}
function parseTranscriptEntries(text: string): SessionStoreEntry[] {
const entries: SessionStoreEntry[] = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
// Skip a line that is not JSON, as the SDK's own transcript reader does.
continue;
}
if (isSessionStoreEntry(parsed)) entries.push(parsed);
}
return entries;
}
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as { type?: unknown }).type === "string"
);
}
export async function revertClaudeConversation(input: {
sdk: ClaudeRewindSdk;
sessionId: string | null;
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
if (!input.sessionId) {
if (!input.sessionId || !input.transcriptPath) {
throw new Error("Claude session is not ready for rewind");
}
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
const fork = await input.sdk.forkSession(input.sessionId, {
upToMessageId: messageId,
transcriptPath: input.transcriptPath,
});
input.setSessionId(fork.sessionId);
}
@@ -44,6 +105,7 @@ export async function revertClaudeConversationAndFiles(input: {
sdk: ClaudeRewindSdk;
query: Query;
sessionId: string | null;
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
@@ -12,7 +12,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk {
async forkSession(
_sessionId: string,
options: { upToMessageId: string },
options: { upToMessageId: string; transcriptPath: string },
): Promise<{ sessionId: string }> {
this.recordedForks.push({ upToMessageId: options.upToMessageId });
return { sessionId: this.nextSessionId };
@@ -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);
});
+3 -3
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();
@@ -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"