Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 8186585201 chore(release): cut 0.9.1 2026-09-22 20:34:13 +02:00
Mohamed Boudra eb665a55a8 docs(changelog): add 0.9.1 entry 2026-09-22 20:30:04 +02:00
Mohamed Boudra d22737c77a chore: keep heap snapshots out of git
The daemon dumps a heap snapshot next to the server package when it nears
its heap limit. Those files are multi-gigabyte debugging artifacts we want
to keep on disk, not stage.
2026-09-22 20:30:04 +02:00
aeb98f813a feat(claude): add Opus 5.5 with its model capabilities (#5200)
feat(claude): add Opus 5.5 with its model capabilities

Combine the catalog and runtime identity work from #5191 with the
per-model effort default from #5197. Claude Code 2.1.280 introduces
Opus 5.5 with 1M context, medium effort, and always-on thinking.
Resolve prefixed minor model releases before their major version so
Opus 5.5 retains its identity and capabilities after a turn.

Co-authored-by: Leonardo G. Urci <leonardoguimaraesurci@gmail.com>
Co-authored-by: Sebastian Galindo <seb@jexhq.com>
Co-authored-by: Rodrigo <rodrigo.parisusao25201@gmail.com>
2026-09-22 20:18:00 +02:00
Mohamed BoudraandBag Raul Cristian c8a7667a03 fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189)
When the client reconnects with a live sync cursor, the daemon answers
with only the agents that changed, and the replica rebuilds the merged
directory from what it already holds. That rebuild went through
projectAgentDirectoryEntry, which returned null for any agent without a
projectPlacement, so those members never reached the merged map,
commitSnapshot treated them as absent, and removeFromDirectory deleted
them from the store.

An agent hydrated from its timeline or restored from the replica cache
has no placement: neither normalizeAgentSnapshot nor deserializeAgent
sets one. Every other path in the same flow derives a placement from the
working directory instead of dropping the agent, so the projection does
the same now via resolveProjectPlacement, and the function is total.

The conversations were always intact on the daemon; only the client lost
them, which is why a full restart brought them back until the next
reconnect drained the sidebar again.

Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
2026-09-22 18:56:05 +02:00
4c051388e1 Reach every Codex conversation in Import session (#5174)
* fix(codex): page through Codex threads when listing importable sessions

Codex answers thread/list with at most 100 rows per response whatever
limit is requested, and returns a cursor for the rest. The adapter read
the first response and returned, so Import session could only ever offer
the newest 100 Codex threads: on a store of 456 threads, a host-wide
search that asks for a 500-thread scan saw 100.

Follow nextCursor until the caller's window is full, and ask Codex to
page by last use so the scanned window matches how the import sheet ranks
candidates. Unknown keys are ignored by Codex, so older builds keep their
creation-time order.

Closes #5073

Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>

* fix(codex): end the thread scan on a page that carries no threads

The previous guard only caught a cursor that repeated immediately, so a
Codex cursor cycle across two or more pages (A to B to A) kept paging
for ever behind a caller that had already timed out.

Every page that continues the scan now has to carry rows, which bounds
the loop by the requested window whatever cursors Codex hands back.

* fix(codex): keep a thread once when the scan sees it on two pages

Paging by last use means a thread touched while the scan is running
sorts to the front and comes back on a later page, so the same
conversation could be offered twice and displace another one inside the
window.

Identify each thread as it arrives and keep it once. A page that brings
nothing new now ends the scan, which also covers a cursor that cycles
while still serving rows.

---------

Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-22 17:20:31 +02:00
28 changed files with 489 additions and 90 deletions
+3
View File
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 0.9.1 - 2026-09-22
### Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
### Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
## 0.9.0 - 2026-09-22
### Added
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.0",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.0",
"version": "0.9.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36196,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37262,12 +37262,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/server": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37514,10 +37514,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37528,7 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37772,7 +37772,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.0",
"version": "0.9.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38668,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38697,14 +38697,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38714,8 +38714,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38737,7 +38737,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38764,7 +38764,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38982,16 +38982,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.0",
"@getpaseo/highlight": "0.9.0",
"@getpaseo/plugin": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39895,7 +39895,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.0",
"version": "0.9.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => {
).toEqual(directoryPlacement);
store.clearSession(serverId);
});
it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => {
const serverId = "agent-replica-timeline-membership";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
// A conversation opened from history arrives over its timeline, without the
// project placement the directory carries, and is a member from then on.
replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only"));
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
replica.commitChanges([], [], []);
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
store.clearSession(serverId);
});
});
@@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import { AgentStoreProjection } from "./internal/agent-store";
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null {
return agent.projectPlacement
? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement }
: null;
// A member hydrated from its timeline or restored from the replica cache carries
// no placement. Every other path in this flow derives one from the working
// directory, so this one does too; returning nothing here would drop the member
// from the merged directory and delete the conversation on the next catch-up.
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry {
return {
agent: projectAgentSnapshot(agent),
project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }),
};
}
export interface AgentLifecycleToken {
@@ -164,8 +170,7 @@ export class AgentDirectoryReplica {
const previous = this.storeProjection.snapshot();
const merged = new Map<string, FetchAgentsEntry>();
for (const agent of previous.values()) {
const entry = projectAgentDirectoryEntry(agent);
if (entry) merged.set(agent.id, entry);
merged.set(agent.id, projectAgentDirectoryEntry(agent));
}
for (const entry of entries) merged.set(entry.agent.id, entry);
const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({
@@ -640,6 +640,63 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("reconciles agent changes on top of the accepted cached baseline", async () => {
const serverId = "cached-agent-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "cached-agent");
const releaseNetwork = client.holdAgentFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map([[cachedAgent.id, cachedAgent]]),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "g", afterSeq: 7 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshAgents();
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
releaseNetwork({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastAgentOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true);
directory.dispose();
});
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
const serverId = "late-directory-cache";
serverIds.add(serverId);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/server": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+6 -1
View File
@@ -52,10 +52,15 @@ interface ProviderDiagnostic {
}
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-opus-5-5",
model: "Opus 5.5",
descriptionFragment: "Latest release",
},
{
id: "claude-opus-5",
model: "Opus 5",
descriptionFragment: "Latest release",
descriptionFragment: "Previous release",
},
{
id: "claude-fable-5-1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.0",
"version": "0.9.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.9.0",
"version": "0.9.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -65,8 +65,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -76,8 +76,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.9.0",
"version": "0.9.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -106,11 +106,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.0",
"@getpaseo/highlight": "0.9.0",
"@getpaseo/plugin": "0.9.0",
"@getpaseo/protocol": "0.9.0",
"@getpaseo/relay": "0.9.0",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -476,7 +476,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
force: false,
});
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
expect(models.map((model) => model.id)).toContain("claude-fable-5");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
@@ -11,6 +11,7 @@ interface ClaudeModelManifestEntry {
minimumClaudeCodeVersion?: string;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
defaultThinkingOptionId?: ClaudeEffortLevel;
supportsThinkingDisabled?: boolean;
supportsFastMode?: boolean;
}
@@ -34,10 +35,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-opus-5-5",
label: "Opus 5.5",
description: "Opus 5.5 · Latest release",
defaultPriority: 3,
minimumClaudeCodeVersion: "2.1.280",
defaultThinkingOptionId: "medium",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-opus-5",
label: "Opus 5",
description: "Opus 5 · Latest release",
description: "Opus 5 · Previous release",
defaultPriority: 2,
minimumClaudeCodeVersion: "2.1.219",
contextWindowMaxTokens: 1_000_000,
@@ -157,9 +169,14 @@ export const CLAUDE_MODEL_MANIFEST = [
},
] as const satisfies readonly ClaudeModelManifestEntry[];
function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel {
return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID;
}
function buildThinkingOptions(
effortLevels: readonly ClaudeEffortLevel[] | undefined,
supportsThinkingDisabled: boolean,
defaultThinkingOptionId: ClaudeEffortLevel,
): AgentSelectOption[] | undefined {
if (!effortLevels) {
return undefined;
@@ -170,7 +187,7 @@ function buildThinkingOptions(
...effortLevels.map((id) => ({
id,
label: CLAUDE_EFFORT_LABELS[id],
...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}),
...(id === defaultThinkingOptionId ? { isDefault: true } : {}),
})),
];
@@ -196,6 +213,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
const thinkingOptions = buildThinkingOptions(
model.effortLevels,
model.supportsThinkingDisabled === true,
getDefaultThinkingOptionId(model),
);
const definition: AgentModelDefinition = {
provider: "claude",
@@ -214,7 +232,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
}
if (thinkingOptions) {
definition.thinkingOptions = thinkingOptions;
definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID;
definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model);
}
definitions.push(definition);
if (!("aliases" in model) || !model.aliases) {
@@ -286,7 +304,7 @@ export function resolveClaudeDisabledThinkingForModel(
supported:
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
fallbackThinkingOptionId:
model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined,
model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined,
};
}
@@ -350,7 +368,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
/**
* Normalize a Claude Code runtime/config model string to a known manifest ID.
* Runtime metadata may include provider prefixes such as Bedrock model IDs; feature
* gates should use normalizeClaudeManifestModelId instead.
* gates should use normalizeClaudeManifestModelId instead. The prefixed matches are
* unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the
* "claude-opus-5" entry.
*/
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const normalizedManifestModelId = normalizeClaudeManifestModelId(value);
@@ -363,13 +383,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
return null;
}
const singleSegmentMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
const runtimeMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (singleSegmentMatch) {
const normalizedModelId = normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
if (runtimeMatch) {
const normalizedModelId = normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
trimmed.toLowerCase().includes("[1m]"),
);
if (normalizedModelId) {
@@ -377,17 +398,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
}
}
const runtimeMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
const singleSegmentMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
if (!singleSegmentMatch) {
return null;
}
return normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
return normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
trimmed.toLowerCase().includes("[1m]"),
);
}
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
return configDir;
}
function createCatalogClient(claudeCodeVersion = "2.1.219"): ClaudeAgentClient {
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
@@ -50,6 +50,7 @@ describe("getClaudeModels", () => {
it("returns all claude models", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5-5",
"claude-opus-5",
"claude-fable-5-1",
"claude-fable-5",
@@ -72,7 +73,7 @@ describe("getClaudeModels", () => {
const models = getClaudeModels();
const defaults = models.filter((m) => m.isDefault);
expect(defaults).toHaveLength(1);
expect(defaults[0].id).toBe("claude-opus-5");
expect(defaults[0].id).toBe("claude-opus-5-5");
});
it("defines context window sizes in the catalog", () => {
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual(
new Map([
["claude-opus-5-5", 1_000_000],
["claude-opus-5", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000],
@@ -109,6 +111,11 @@ describe("getClaudeModels", () => {
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5");
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
});
it("derives thinking options from model effort capabilities", () => {
@@ -169,6 +176,8 @@ describe("getClaudeModels", () => {
});
it.each([
["claude-opus-5-5", false, "medium"],
["claude-opus-5-5-20260401", false, "medium"],
["claude-opus-5", true, "high"],
["claude-opus-5-20260724", true, "high"],
["claude-sonnet-5", true, "high"],
@@ -417,6 +426,19 @@ describe("normalizeClaudeRuntimeModelId", () => {
"claude-opus-4-8",
);
});
it("does not collapse a prefixed minor release onto the major it extends", () => {
expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5");
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe(
"claude-opus-5-5",
);
expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe(
"claude-fable-5-1",
);
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe(
"claude-opus-5",
);
});
});
describe("parseClaudeCodeVersion", () => {
@@ -438,7 +460,7 @@ describe("findClaudeModel", () => {
describe("Claude Opus 5 catalog", () => {
it("offers a single Opus 5 entry with a 1M context window", () => {
const opus5Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-opus-5"))
.filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(opus5Models).toEqual([
@@ -515,6 +537,41 @@ describe("Claude Fable 5.1 catalog", () => {
});
});
describe("Claude Opus 5.5 catalog", () => {
it("offers one Opus 5.5 entry with a 1M context window", () => {
const opus55Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-opus-5-5"))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(opus55Models).toEqual([
{ id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 },
]);
});
it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => {
const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5");
expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(opus55?.defaultThinkingOptionId).toBe("medium");
expect(
opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
).toEqual(["medium"]);
});
it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => {
expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5");
expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5");
expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5");
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
@@ -523,6 +580,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
});
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
@@ -5991,6 +5991,138 @@ describe("Codex app-server provider", () => {
});
describe("Codex importable sessions", () => {
const CODEX_THREAD_PAGE_CAP = 100;
// Codex answers thread/list with at most 100 rows per response whatever limit
// the caller asks for, and hands back a cursor for the remaining threads.
function cappedThreadListHandler(threads: Array<Record<string, unknown>>) {
return (input: unknown) => {
const params = (input ?? {}) as {
limit?: number;
cursor?: string;
cwd?: string;
sortKey?: string;
};
const recencyKey = params.sortKey === "updated_at" ? "updatedAt" : "createdAt";
const matching = threads
.filter((thread) => !params.cwd || thread.cwd === params.cwd)
.sort((a, b) => Number(b[recencyKey]) - Number(a[recencyKey]));
const offset = Number(params.cursor ?? 0);
const page = matching.slice(
offset,
offset + Math.min(params.limit ?? CODEX_THREAD_PAGE_CAP, CODEX_THREAD_PAGE_CAP),
);
const nextOffset = offset + page.length;
return {
data: page,
nextCursor: nextOffset < matching.length ? String(nextOffset) : null,
};
};
}
function threadsUsedInReverseCreationOrder(count: number) {
// The most recently used thread is also the oldest one created, so a scan
// that follows Codex's creation-time page order misses it.
return Array.from({ length: count }, (_, index) => ({
id: `thread-${index}`,
cwd: "/workspace/project-a",
preview: `Session ${index}`,
createdAt: index,
updatedAt: count - index,
}));
}
test("fills the requested window across the pages Codex caps at 100 rows", async () => {
const threads = threadsUsedInReverseCreationOrder(250);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler([
...threads,
{ id: "other-project", cwd: "/workspace/project-b", createdAt: 9000, updatedAt: 9000 },
]),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({
limit: 240,
cwd: "/workspace/project-a",
});
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 240).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps the scanned window on the most recently used threads", async () => {
const threads = threadsUsedInReverseCreationOrder(550);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler(threads),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 700 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 500).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps a thread once when it moves onto a later page mid-scan", async () => {
// A thread used while the scan is paging sorts to the front and comes back
// on the next page.
const appServer = createFakeCodexAppServer({
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
return cursor
? {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 9000 },
{ id: "thread-3", cwd: "/workspace/project-a", updatedAt: 1000 },
],
nextCursor: null,
}
: {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 3000 },
{ id: "thread-2", cwd: "/workspace/project-a", updatedAt: 2000 },
],
nextCursor: "page-2",
};
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual([
"thread-1",
"thread-2",
"thread-3",
]);
appServer.assertNoErrors();
});
test("stops scanning when Codex pages without handing back a new thread", async () => {
const appServer = createFakeCodexAppServer({
// A cursor cycle: the two pages after the first serve threads the scan
// already holds and point at each other, so it can never fill its window.
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
const thread = { id: "thread-1", cwd: "/workspace/project-a" };
if (cursor === "page-a") return { data: [thread], nextCursor: "page-b" };
if (cursor === "page-b") return { data: [thread], nextCursor: "page-a" };
return { data: [thread], nextCursor: "page-a" };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["thread-1"]);
appServer.assertNoErrors();
});
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
const allThreads = [
{
@@ -6071,7 +6203,10 @@ describe("Codex importable sessions", () => {
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
},
},
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
{
method: "thread/list",
params: { limit: 50, sortKey: "updated_at", cwd: "/workspace/project-a" },
},
]);
});
});
@@ -914,6 +914,60 @@ const CodexModelListResponseSchema = z.object({
.optional(),
});
/**
* Read the newest `window.limit` threads Codex knows about.
*
* Codex caps every `thread/list` response at 100 rows whatever limit it is
* given (codex-cli 0.153 and 0.155), so one request only ever sees the newest
* page and hands back a cursor for the rest. Follow that cursor until the
* caller's window is full or Codex runs out of threads.
*/
async function readCodexThreadWindow(
client: Pick<CodexAppServerClientLike, "request">,
logger: Logger,
window: { limit: number; cwd?: string },
): Promise<Array<Record<string, unknown>>> {
// A thread updated while the scan is paging moves under `updated_at` order
// and can come back on a later page, so identify each one and keep it once.
// A row Codex sends without an id stands for itself.
const threads = new Map<unknown, Record<string, unknown>>();
let cursor: string | undefined;
while (threads.size < window.limit) {
const response = toObjectRecord(
await client.request("thread/list", {
limit: window.limit - threads.size,
// Rank the window by last use. Codex pages by creation time by default,
// which drops an old conversation that is still being worked in.
// Older Codex builds ignore the unknown key and keep that order.
sortKey: "updated_at",
...(window.cwd ? { cwd: window.cwd } : {}),
...(cursor ? { cursor } : {}),
}),
);
const page = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const sizeBeforePage = threads.size;
for (const thread of page) {
const identity = typeof thread.id === "string" ? thread.id : thread;
if (!threads.has(identity)) threads.set(identity, thread);
}
const nextCursor = typeof response?.nextCursor === "string" ? response.nextCursor : undefined;
if (!nextCursor) break;
if (threads.size === sizeBeforePage) {
// Every page that continues the scan brings a thread the scan has not
// seen, so the loop is bounded by the window. A page that brings none
// would let a Codex-side cursor bug (a stuck cursor, or a cycle) page for
// ever behind a caller that has already timed out.
logger.warn(
{ cursor: nextCursor },
"codex thread/list returned no new threads mid-scan, stopping the session scan",
);
break;
}
cursor = nextCursor;
}
return [...threads.values()];
}
function filterCodexThreadsByCwd(
threads: Array<Record<string, unknown>>,
cwd: string | undefined,
@@ -7143,13 +7197,10 @@ export class CodexAppServerAgentClient implements AgentClient {
// filtering since most threads will be from other cwds, then keep the
// local realpath-aware filter for symlink-equivalent workspace paths.
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
const response = toObjectRecord(
await client.request("thread/list", {
limit: listLimit,
...(options?.cwd ? { cwd: options.cwd } : {}),
}),
);
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const allThreads = await readCodexThreadWindow(client, this.logger, {
limit: listLimit,
cwd: options?.cwd,
});
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
return threads.slice(0, limit).map((thread) => {
const threadId = typeof thread.id === "string" ? thread.id : "";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"type": "module",
"scripts": {