mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
Compare commits
9
Commits
v0.11.0
...
c32d82fd40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c32d82fd40 | ||
|
|
499665efbb | ||
|
|
d46164ce0d | ||
|
|
a591320409 | ||
|
|
ee6deda079 | ||
|
|
0555c877d0 | ||
|
|
b6454d7ea4 | ||
|
|
1b88f16124 | ||
|
|
1fae8e89f7 |
+11
-4
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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 +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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user