From f1d34cb540a063cdbda5a94f8a8e185f78024f20 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:36:46 +0100 Subject: [PATCH 1/4] feat(pi): import terminal Pi sessions with the existing history importer The agent-session scanner now discovers each enabled Pi instance's session JSONL files, imports only the active branch's user and assistant text, and binds the imported thread to the session file path so the next turn resumes the native session. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionImporter.test.ts | 48 ++++-- .../src/project/AgentSessionImporter.ts | 13 +- .../src/project/AgentSessionScanner.test.ts | 138 +++++++++++++++- .../server/src/project/AgentSessionScanner.ts | 148 ++++++++++++++++-- .../components/onboarding/WelcomeWizard.tsx | 52 +++--- docs/user/welcome-wizard.md | 9 +- packages/contracts/src/agentSessions.ts | 2 +- packages/provider-pi/src/server/usage.ts | 27 ++-- 8 files changed, 358 insertions(+), 79 deletions(-) diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 69026f91202af..4248e51b55977 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { + type AgentSessionSource, EventId, ProjectId, ProviderDriverKind, @@ -50,7 +51,7 @@ const persistence = Layer.mergeAll( function candidate( modelSelection: typeof CODEX_MODEL_SELECTION, providerSessionId: string, - provider: "codex" | "claudeAgent" = "codex", + provider: AgentSessionSource = "codex", ) { const providerInstanceId = modelSelection.instanceId; return { @@ -82,6 +83,12 @@ function candidate( } satisfies AgentSessionScanner.AgentSessionRecentThread; } +const PI_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("pi"), model: "default" }; + +// Pi threads are owned by their session file path, not the session UUID. +const nativeId = ({ source }: ReturnType) => + source.provider === "pi" ? source.filePath : source.providerSessionId; + function importId(source: ReturnType) { return ThreadId.make( `import:${source.source.providerInstanceId}:${source.source.providerSessionId}`, @@ -95,19 +102,17 @@ const seedOwner = Effect.fn(function* ( const sink = yield* EventSink.EventSinkV2; const ids = yield* IdAllocator.IdAllocatorV2; const { providerInstanceId, providerSessionId, provider } = source.source; + const nativeThreadId = nativeId(source); const threadId = ThreadId.make(`owner:${providerInstanceId}:${providerSessionId}`); const driver = ProviderDriverKind.make(provider); const providerThread: OrchestrationV2ProviderThread = { - id: ids.derive.providerThread({ - driver, - nativeThreadId: providerSessionId, - }), + id: ids.derive.providerThread({ driver, nativeThreadId }), driver, providerInstanceId, providerSessionId: null, appThreadId: threadId, ownerNodeId: null, - nativeThreadRef: { driver, nativeId: providerSessionId, strength: "strong" }, + nativeThreadRef: { driver, nativeId: nativeThreadId, strength: "strong" }, nativeConversationHeadRef: null, status: "idle", firstRunOrdinal: 1, @@ -252,10 +257,12 @@ it.effect( "claudeAgent", ); const deleted = candidate(CODEX_MODEL_SELECTION, "deleted"); + const ownedPi = candidate(PI_MODEL_SELECTION, "owned-pi", "pi"); const owners = [ yield* seedOwner(active), yield* seedOwner(archived, "archived"), yield* seedOwner(deleted, "deleted"), + yield* seedOwner(ownedPi), ]; const before = yield* Effect.forEach(owners, (id) => projections.getThreadRecords(id, ["providerThreads"]), @@ -266,10 +273,19 @@ it.effect( "claudeAgent", ); const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); - const importer = yield* makeImporter([active, archived, deleted, otherInstance, unowned]); + const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi"); + const importer = yield* makeImporter([ + active, + archived, + deleted, + ownedPi, + otherInstance, + unowned, + unownedPi, + ]); - expect(yield* importer.run).toEqual({ importedCount: 2, skippedCount: 0 }); - for (const source of [active, archived, deleted]) { + expect(yield* importer.run).toEqual({ importedCount: 3, skippedCount: 0 }); + for (const source of [active, archived, deleted, ownedPi]) { const threadId = importId(source); expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none()); expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]); @@ -286,8 +302,8 @@ it.effect( "event" in stored ? [stored.event.threadId] : [], ), ), - ).toEqual(new Set([importId(otherInstance), importId(unowned)])); - for (const source of [otherInstance, unowned]) { + ).toEqual(new Set([importId(otherInstance), importId(unowned), importId(unownedPi)])); + for (const source of [otherInstance, unowned, unownedPi]) { const threadId = importId(source); const records = yield* projections.getThreadRecords(threadId, [ "messages", @@ -296,15 +312,13 @@ it.effect( expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]); expect(records.thread.historyOrigin).toBe("v1_import"); expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); - expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe( - source.source.providerSessionId, - ); + expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source)); expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ providerInstanceId: source.source.providerInstanceId, resumeCursor: - source.source.provider === "codex" - ? { threadId: source.source.providerSessionId } - : { threadId, resume: source.source.providerSessionId }, + source.source.provider === "claudeAgent" + ? { threadId, resume: source.source.providerSessionId } + : { threadId: nativeId(source) }, runtimePayload: { importedTranscripts: [source.source] }, }); } diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 11f168ff1d7be..038b488635053 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -250,6 +250,9 @@ const make = Effect.gen(function* () { }); } const driver = ProviderDriverKind.make(thread.source); + // Pi resumes by session file path; its session UUID only names the thread. + const nativeThreadId = + thread.source === "pi" ? source.filePath : thread.providerSessionId; const persistRuntime = runtimes.upsert( { threadId, @@ -260,9 +263,9 @@ const make = Effect.gen(function* () { status: "stopped", lastSeenAt: thread.updatedAt, resumeCursor: - thread.source === "codex" - ? { threadId: thread.providerSessionId } - : { threadId, resume: thread.providerSessionId }, + thread.source === "claudeAgent" + ? { threadId, resume: thread.providerSessionId } + : { threadId: nativeThreadId }, runtimePayload: { cwd: project.workspaceRoot }, }, { onConflict: "ignore" }, @@ -288,7 +291,7 @@ const make = Effect.gen(function* () { const providerThreadId = idAllocator.derive.providerThread({ driver, providerInstanceId: thread.providerInstanceId, - nativeThreadId: thread.providerSessionId, + nativeThreadId, }); const createdAt = dateTime(thread.createdAt); const updatedAt = dateTime(thread.updatedAt); @@ -337,7 +340,7 @@ const make = Effect.gen(function* () { ownerNodeId: null, nativeThreadRef: { driver, - nativeId: thread.providerSessionId, + nativeId: nativeThreadId, strength: "strong", }, nativeConversationHeadRef: null, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index baf0f27f9edb3..cfe5db3a6f110 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -46,6 +46,7 @@ const layerProjectStore = (importedWorkspaceRoots: ReadonlyArray) => interface ScannerTestInput { readonly claudeHomePath: string; readonly codexHomePath: string; + readonly piAgentDir?: string; readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; @@ -65,6 +66,19 @@ const layerScannerTest = (input: ScannerTestInput) => driver: ProviderDriverKind.make("codex"), config: { homePath: input.codexHomePath }, }, + // Keeps the default Pi instance off the developer's real ~/.pi. + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { + name: "PI_CODING_AGENT_DIR", + value: input.piAgentDir ?? `${input.claudeHomePath}/pi-agent`, + sensitive: false, + }, + ], + config: {}, + }, ...input.providerInstances, }, }), @@ -561,9 +575,32 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const piAgentDir = yield* makeTempDir("t3code-pi-agent-"); + const piSessionDir = yield* makeTempDir("t3code-pi-sessions-"); const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); - + const piWorkspace = yield* makeTempDir("t3code-workspace-pi-"); + const piFlatWorkspace = yield* makeTempDir("t3code-workspace-pi-flat-"); + + // Pi nests sessions per cwd by default and keeps a custom session directory flat. + for (const [filePath, cwd, mtime] of [ + [ + path.join(piAgentDir, "sessions", "--slug--", "2026-01-03_pi.jsonl"), + piWorkspace, + "2026-01-03T00:00:00.000Z", + ], + [ + path.join(piSessionDir, "2026-01-04_pi-flat.jsonl"), + piFlatWorkspace, + "2026-01-04T00:00:00.000Z", + ], + ] as const) { + yield* writeTranscript({ + filePath, + contents: `${encodeTranscriptRecord({ type: "session", version: 3, id: "pi", cwd })}\n`, + mtimeMs: Date.parse(mtime), + }); + } yield* writeTranscript({ filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), contents: claudeSessionLine(claudeWorkspace), @@ -598,12 +635,28 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], config: {}, }, + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [{ name: "PI_CODING_AGENT_DIR", value: piAgentDir, sensitive: false }], + config: {}, + }, + [ProviderInstanceId.make("pi-flat")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { name: "PI_CODING_AGENT_SESSION_DIR", value: piSessionDir, sensitive: false }, + ], + config: {}, + }, }, }); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([ - codexWorkspace, - claudeWorkspace, + expect(result.candidates.map((candidate) => [candidate.path, candidate.sources])).toEqual([ + [piFlatWorkspace, ["pi"]], + [piWorkspace, ["pi"]], + [codexWorkspace, ["codex"]], + [claudeWorkspace, ["claudeAgent"]], ]); }), ); @@ -983,13 +1036,14 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); describe("recentThreads", () => { - it.effect("imports recent Claude and Codex sessions for the selected project only", () => + it.effect("imports recent Claude, Codex, and Pi sessions for the selected project only", () => Effect.gen(function* () { const path = yield* Path.Path; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); yield* TestClock.setTime(nowMs); const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const piAgentDir = yield* makeTempDir("t3code-pi-agent-"); const workspace = yield* makeTempDir("t3code-workspace-"); const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); @@ -1053,21 +1107,95 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ].join("\n"), mtimeMs: nowMs - 60 * 60 * 1000, }); + const piMessage = ( + id: string, + parentId: string | null, + message: { readonly role: string; readonly content: unknown }, + ) => + encodeTranscriptRecord({ + type: "message", + id, + parentId, + timestamp: "2026-08-24T11:00:00.000Z", + message, + }); + // The user rewound to the first prompt; the abandoned branch stays in the file. + yield* writeTranscript({ + filePath: path.join(piAgentDir, "sessions", "--selected--", "2026-08-24_pi.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session", + version: 3, + id: "pi-recent", + cwd: workspace, + }), + piMessage("a1", null, { role: "user", content: "Plan the change" }), + piMessage("a2", "a1", { + role: "assistant", + content: [{ type: "text", text: "Abandoned answer" }], + }), + piMessage("a3", "a2", { role: "user", content: "Abandoned follow-up" }), + piMessage("b1", "a1", { + role: "assistant", + content: [ + { type: "thinking", thinking: "Hidden reasoning" }, + { type: "toolCall", id: "call-1", name: "bash", arguments: {} }, + ], + }), + piMessage("b2", "b1", { + role: "toolResult", + content: [{ type: "text", text: "Tool output" }], + }), + piMessage("b3", "b2", { + role: "assistant", + content: [{ type: "text", text: "Active answer" }], + }), + encodeTranscriptRecord({ + type: "session_info", + id: "b4", + parentId: "b3", + name: "Pi refactor", + }), + ].join("\n"), + mtimeMs: nowMs - 30 * 60 * 1000, + }); + // Version 1 sessions predate the entry tree. + yield* writeTranscript({ + filePath: path.join(piAgentDir, "sessions", "--selected--", "2025-01-01_pi-v1.jsonl"), + contents: [ + encodeTranscriptRecord({ type: "session", id: "pi-v1", cwd: workspace }), + encodeTranscriptRecord({ + type: "message", + message: { role: "user", content: "Legacy prompt" }, + }), + encodeTranscriptRecord({ + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "Legacy answer" }] }, + }), + ].join("\n"), + mtimeMs: nowMs - 2 * 24 * 60 * 60 * 1000, + }); const threads = yield* runRecentThreads({ claudeHomePath, codexHomePath, + piAgentDir, workspaceRoot: workspace, }); expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "pi-recent", "codex-recent", "claude-recent", + "pi-v1", ]); expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Plan the change", "Active answer"], ["Review this code", "Looks good"], ["Fix the project", "Done"], + ["Legacy prompt", "Legacy answer"], ]); + expect(threads[0]).toMatchObject({ source: "pi", title: "Pi refactor", model: null }); }), ); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 7ec79c38a38a9..b1a9b953acd90 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -32,7 +32,9 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { resolvePiSessionsDir } from "@t3tools/provider-pi/server/usage"; import * as ServerSettings from "../serverSettings.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { @@ -78,6 +80,10 @@ const CodexTurnMetadata = Schema.Struct({ const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), + // Pi entries form a tree through id/parentId; session_info entries carry its name. + id: Schema.optional(Schema.String), + parentId: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.String), timestamp: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), @@ -287,6 +293,35 @@ function deriveTitle(source: AgentSessionSource, text: string): string | undefin return request.trim().split("\n")[0]?.slice(0, 100).trim() || undefined; } +// Pi keeps abandoned branches in the file; its active branch runs from the last entry to the root. +function activePiBranch( + records: ReadonlyArray, +): ReadonlyArray { + const entries = records.filter((record) => record.type !== "session"); + const leaf = entries.at(-1); + // Version 1 sessions predate the tree and are linear. + if (leaf?.id === undefined) return entries; + const byId = new Map( + entries.flatMap((entry) => (entry.id === undefined ? [] : [[entry.id, entry] as const])), + ); + const branch: Array = []; + for ( + let entry: DecodedTranscriptRecord | undefined = leaf; + entry !== undefined && branch.length < entries.length; + entry = entry.parentId ? byId.get(entry.parentId) : undefined + ) { + branch.push(entry); + } + return branch.toReversed(); +} + +function isPiConversationRecord(record: DecodedTranscriptRecord): boolean { + return ( + record.type === "message" && + (record.message?.role === "user" || record.message?.role === "assistant") + ); +} + export function parseAgentSessionTranscript( input: AgentSessionTranscriptMetadata & { readonly contents: string; @@ -311,8 +346,16 @@ function parseAgentSessionRecords( // Claude filenames are session IDs. Codex rollout filenames include extra // timestamp text, so only transcript metadata can provide a resumable ID. let providerSessionId = - input.source === "codex" ? "" : (claudeFields.sessionId ?? input.fallbackSessionId); - const title = claudeFields.title ?? null; + input.source === "codex" + ? "" + : input.source === "pi" + ? records.find((record) => record.type === "session")?.id?.trim() || input.fallbackSessionId + : (claudeFields.sessionId ?? input.fallbackSessionId); + // Pi titles a session by its latest session_info on any branch; an empty name clears it. + const title = + input.source === "pi" + ? records.findLast((record) => record.type === "session_info")?.name?.trim() || null + : (claudeFields.title ?? null); let model = claudeFields.model ?? null; let derivedTitle: string | undefined; let hasCodexSessionId = false; @@ -403,8 +446,20 @@ function parseAgentSessionRecords( }; let recordIndex = -1; - for (const record of records) { + for (const record of input.source === "pi" ? activePiBranch(records) : records) { recordIndex += 1; + if (input.source === "pi") { + if (!isPiConversationRecord(record) || record.message === undefined) continue; + const text = extractText(record.message.content); + if (text.length === 0) continue; + retainMessage({ + role: record.message.role === "user" ? "user" : "assistant", + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } if (input.source === "claudeAgent") { if (isHiddenClaudeRecord(record)) continue; if (record.type !== "user" && record.type !== "assistant") continue; @@ -507,6 +562,8 @@ function shouldRetainDecodedRecord( source: AgentSessionSource, record: DecodedTranscriptRecord, ): boolean { + // Any Pi entry can be a link on the active branch. + if (source === "pi") return true; if (source === "claudeAgent") { if (isHiddenClaudeRecord(record) || (record.type !== "user" && record.type !== "assistant")) { return false; @@ -850,8 +907,18 @@ export const make = Effect.gen(function* () { claudeFields = foldClaudeFields(claudeFields, decoded.value); } if (shouldRetainDecodedRecord(source, decoded.value)) { - records.push(decoded.value); - historyBytes += recordBytes; + if ( + source === "pi" && + decoded.value.type === "message" && + !isPiConversationRecord(decoded.value) + ) { + // Tool output can dwarf the conversation; keep only the tree links. + const { message: _message, ...links } = decoded.value; + records.push(links); + } else { + records.push(decoded.value); + historyBytes += recordBytes; + } } } recordBytes = 0; @@ -1030,6 +1097,57 @@ export const make = Effect.gen(function* () { }, ); + // Pi nests sessions in one directory per cwd, but a custom session directory holds them flat. + const discoverPiTranscripts = Effect.fn("AgentSessionScanner.discoverPiTranscripts")(function* ( + sessionsDir: string, + providerInstanceId: ProviderInstanceId, + operationBudget: number, + ) { + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const transcripts: Array = []; + const addTranscript = Effect.fnUntraced(function* (filePath: string) { + if (operationsRemaining <= 0) { + truncated = true; + return; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if (Option.isSome(stats) && stats.value.type === "File" && Option.isSome(stats.value.mtime)) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + }); + + for (const entry of yield* readDirectory(sessionsDir)) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const entryPath = path.join(sessionsDir, entry); + if (entry.endsWith(".jsonl")) { + yield* addTranscript(entryPath); + continue; + } + for (const nested of yield* readDirectory(entryPath)) { + if (nested.endsWith(".jsonl")) yield* addTranscript(path.join(entryPath, nested)); + } + } + return { transcripts, truncated }; + }); + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( source: AgentSessionSource, transcripts: ReadonlyArray, @@ -1081,7 +1199,7 @@ export const make = Effect.gen(function* () { const raw: Array = []; let truncated = false; - for (const source of ["claudeAgent", "codex"] as const) { + for (const source of ["claudeAgent", "codex", "pi"] as const) { const instances: Array<{ readonly instanceId: ProviderInstanceId; readonly config: ProviderInstanceConfig; @@ -1119,7 +1237,13 @@ export const make = Effect.gen(function* () { typeof configuredHome === "string" ? configuredHome : hostEnvironment[homeVariable]; let homePath: string; - if (source === "claudeAgent") { + if (source === "pi") { + const environment = yield* mergeProviderInstanceEnvironment( + instance.environment, + hostEnvironment, + ).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDir)); + homePath = resolvePiSessionsDir(environment, homeDir, path); + } else if (source === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); if (Option.isNone(config)) continue; homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); @@ -1155,9 +1279,13 @@ export const make = Effect.gen(function* () { truncated = true; continue; } - const discovered = yield* source === "claudeAgent" - ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) - : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + const discover = + source === "claudeAgent" + ? discoverClaudeTranscripts + : source === "codex" + ? discoverCodexTranscripts + : discoverPiTranscripts; + const discovered = yield* discover(home.homePath, home.providerInstanceId, operationBudget); truncated ||= discovered.truncated; transcriptCandidates.push(...discovered.transcripts); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 50d65192c0249..6d258c6d9061d 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -15,6 +15,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + AgentSessionSource, AuthTerminalOperateScope, CommandId, defaultInstanceIdForDriver, @@ -1403,7 +1404,7 @@ function ImportStep({

- Looking for projects from Claude Code and Codex… + Looking for projects from Claude Code, Codex and Pi…

@@ -1418,7 +1419,7 @@ function ImportStep({ return ( {candidates.length > 0 ? (
@@ -1480,7 +1481,7 @@ function ImportStep({
) : scanCandidates.length === 0 ? (

- No existing Claude Code or Codex projects found. + No existing Claude Code, Codex or Pi projects found.

) : null} {scan.data?.truncated ? ( @@ -1715,41 +1716,38 @@ function ImportCandidateRow({ ); } +const IMPORT_SOURCE_NAMES: Record = { + claudeAgent: "Claude Code", + codex: "Codex", + pi: "Pi", +}; + function ImportRowMeta({ sources, threadCount, lastActiveAt, }: { - readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null; + readonly sources: ReadonlyArray | null; readonly threadCount: number; readonly lastActiveAt: string | null; }) { const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt); const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value; return ( - - - {sources?.includes("claudeAgent") ? ( - - - - ) : null} - - - {sources?.includes("codex") ? ( - - - - ) : null} - + + {AgentSessionSource.literals.map((source) => ( + + {sources?.includes(source) ? ( + + + + ) : null} + + ))} {threadCount} {age} diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index d4604fa7e6668..6e362a0629188 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -59,7 +59,7 @@ terminal metadata while the terminal process can use them. ## Import your projects -T3 Code finds directories that Claude Code or Codex has used. Git repositories +T3 Code finds directories that Claude Code, Codex or Pi has used. Git repositories are listed first, newest activity on top. When the remote is on GitHub, the group shows the repository as `owner/name`. Clones with the same remote share one group. Directories that are not git repositories sit under "Other folders". @@ -74,8 +74,11 @@ offered. A large or malformed history can reach the scan limit. T3 Code keeps the projects it found and warns when projects or conversations may be missing. -Imported projects include Codex and Claude conversations active within the last -30 days. You can continue those conversations in T3 Code. +Imported projects include Claude, Codex and Pi conversations active within the last +30 days. You can continue those conversations in T3 Code. Pi sessions are read +from each enabled Pi provider's session directory, which follows +`PI_CODING_AGENT_SESSION_DIR` and `PI_CODING_AGENT_DIR` in its environment. A +Pi conversation imports its active branch, the one Pi reopens. Conversation import is best effort. T3 Code keeps the first user prompt and the newest remaining visible user and assistant messages, with 200 messages total. diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index a60d702d9a0e3..93e0e36e4fbd4 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex", "pi"]); export type AgentSessionSource = typeof AgentSessionSource.Type; export const AgentSessionImportSource = Schema.Struct({ diff --git a/packages/provider-pi/src/server/usage.ts b/packages/provider-pi/src/server/usage.ts index 5dadc93e101d9..961a73e838acd 100644 --- a/packages/provider-pi/src/server/usage.ts +++ b/packages/provider-pi/src/server/usage.ts @@ -131,16 +131,21 @@ export const piUsageReader: ProviderUsageReader = { directories: Effect.fn("piUsageReader.directories")(function* ({ environment }) { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; - const sessionDir = environment.PI_CODING_AGENT_SESSION_DIR?.trim(); - if (sessionDir) return [{ dir: path.resolve(expandHomePath(sessionDir, home)) }]; - const agentDir = environment.PI_CODING_AGENT_DIR?.trim(); - return [ - { - dir: path.resolve( - agentDir ? expandHomePath(agentDir, home) : path.join(home, ".pi", "agent"), - "sessions", - ), - }, - ]; + return [{ dir: resolvePiSessionsDir(environment, home, path) }]; }), }; + +/** Where Pi stores session JSONL files for a provider environment, matching Pi's own lookup. */ +export function resolvePiSessionsDir( + environment: NodeJS.ProcessEnv, + home: string, + path: Path.Path, +): string { + const sessionDir = environment.PI_CODING_AGENT_SESSION_DIR?.trim(); + if (sessionDir) return path.resolve(expandHomePath(sessionDir, home)); + const agentDir = environment.PI_CODING_AGENT_DIR?.trim(); + return path.resolve( + agentDir ? expandHomePath(agentDir, home) : path.join(home, ".pi", "agent"), + "sessions", + ); +} From 39bfa144fae450daf9350281c8b9b5b82be2b0f3 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:48:44 +0100 Subject: [PATCH 2/4] refactor(server): simplify Pi session discovery and home resolution Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 2 +- .../server/src/project/AgentSessionScanner.ts | 80 +++++++++++-------- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index cfe5db3a6f110..5c5b95abe7e7e 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1195,7 +1195,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ["Fix the project", "Done"], ["Legacy prompt", "Legacy answer"], ]); - expect(threads[0]).toMatchObject({ source: "pi", title: "Pi refactor", model: null }); + expect(threads[0]).toMatchObject({ source: "pi", title: "Pi refactor" }); }), ); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index b1a9b953acd90..be16d4604f04b 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -315,7 +315,11 @@ function activePiBranch( return branch.toReversed(); } -function isPiConversationRecord(record: DecodedTranscriptRecord): boolean { +function isPiConversationRecord( + record: DecodedTranscriptRecord, +): record is DecodedTranscriptRecord & { + readonly message: typeof TranscriptMessage.Type & { readonly role: "user" | "assistant" }; +} { return ( record.type === "message" && (record.message?.role === "user" || record.message?.role === "assistant") @@ -449,11 +453,11 @@ function parseAgentSessionRecords( for (const record of input.source === "pi" ? activePiBranch(records) : records) { recordIndex += 1; if (input.source === "pi") { - if (!isPiConversationRecord(record) || record.message === undefined) continue; + if (!isPiConversationRecord(record)) continue; const text = extractText(record.message.content); if (text.length === 0) continue; retainMessage({ - role: record.message.role === "user" ? "user" : "assistant", + role: record.message.role, text, createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), codexResponseUser: false, @@ -1114,35 +1118,37 @@ export const make = Effect.gen(function* () { return listDirectory(directory); }; const transcripts: Array = []; - const addTranscript = Effect.fnUntraced(function* (filePath: string) { - if (operationsRemaining <= 0) { - truncated = true; - return; - } - operationsRemaining -= 1; - const stats = yield* statOption(filePath); - if (Option.isSome(stats) && stats.value.type === "File" && Option.isSome(stats.value.mtime)) { - transcripts.push({ - filePath, - mtimeMs: stats.value.mtime.value.getTime(), - providerInstanceId, - size: Number(stats.value.size), - }); - } - }); - for (const entry of yield* readDirectory(sessionsDir)) { if (operationsRemaining <= 0) { truncated = true; break; } const entryPath = path.join(sessionsDir, entry); - if (entry.endsWith(".jsonl")) { - yield* addTranscript(entryPath); - continue; - } - for (const nested of yield* readDirectory(entryPath)) { - if (nested.endsWith(".jsonl")) yield* addTranscript(path.join(entryPath, nested)); + const filePaths = entry.endsWith(".jsonl") + ? [entryPath] + : (yield* readDirectory(entryPath)) + .filter((nested) => nested.endsWith(".jsonl")) + .map((nested) => path.join(entryPath, nested)); + + for (const filePath of filePaths) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } } } return { transcripts, truncated }; @@ -1229,12 +1235,12 @@ export const make = Effect.gen(function* () { const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { - const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; - const configuredHome = instance.environment?.findLast( - (variable) => variable.name === homeVariable, - )?.value; - const environmentHome = - typeof configuredHome === "string" ? configuredHome : hostEnvironment[homeVariable]; + const environmentHome = (name: "CLAUDE_CONFIG_DIR" | "CODEX_HOME") => { + const configured = instance.environment?.findLast( + (variable) => variable.name === name, + )?.value; + return typeof configured === "string" ? configured : hostEnvironment[name]; + }; let homePath: string; if (source === "pi") { @@ -1246,15 +1252,19 @@ export const make = Effect.gen(function* () { } else if (source === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); if (Option.isNone(config)) continue; - homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + homePath = resolveClaudeConfigDir( + config.value.homePath, + environmentHome("CLAUDE_CONFIG_DIR"), + ); } else { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(config)) continue; + const codexHome = environmentHome("CODEX_HOME"); const codexSettings = config.value.homePath.trim().length === 0 && config.value.shadowHomePath.trim().length === 0 && - environmentHome?.trim() - ? { ...config.value, homePath: environmentHome } + codexHome?.trim() + ? { ...config.value, homePath: codexHome } : config.value; const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( Effect.provideService(Path.Path, path), From 8ea344e63c88376321ca24bc91a7d1997ad945b7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:54:20 +0100 Subject: [PATCH 3/4] fix(server): keep Pi's active branch when a message fails to decode Co-Authored-By: GPT 6.1 Sol Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 3 ++- apps/server/src/project/AgentSessionScanner.ts | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 5c5b95abe7e7e..200c6a2667a08 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1142,7 +1142,8 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { { type: "toolCall", id: "call-1", name: "bash", arguments: {} }, ], }), - piMessage("b2", "b1", { + piMessage("bn", "b1", { role: "toolResult", content: null }), + piMessage("b2", "bn", { role: "toolResult", content: [{ type: "text", text: "Tool output" }], }), diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index be16d4604f04b..bf026d9005c1a 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -112,6 +112,13 @@ const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); +const decodePiEntryLinks = Schema.decodeUnknownOption( + Schema.Struct({ + type: TranscriptRecord.fields.type, + id: TranscriptRecord.fields.id, + parentId: TranscriptRecord.fields.parentId, + }), +); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); @@ -904,8 +911,12 @@ export const make = Effect.gen(function* () { reader.write(decoder.decode()); recordCount += 1; if (recordCount > recordLimit) return false; - const decoded = decodeTranscriptValue(reader.finish()); - if (Option.isSome(decoded)) { + const value = reader.finish(); + const decoded = decodeTranscriptValue(value); + if (Option.isNone(decoded) && source === "pi") { + // Pi accepts messages this schema rejects, such as null content; keep their tree links. + records.push(...Option.toArray(decodePiEntryLinks(value))); + } else if (Option.isSome(decoded)) { if (Option.isNone(cwd)) cwd = Option.fromNullOr(extractDecodedCwd(decoded.value)); if (source === "claudeAgent") { claudeFields = foldClaudeFields(claudeFields, decoded.value); From 4f8e38c45719e3574cc7bdebd6d12c1c6496a9dd Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:58:15 +0100 Subject: [PATCH 4/4] fix(web): keep import copy true while Pi is off by default Pi starts disabled, so the setup scan names only the default agents and the user guide says how to import Pi sessions after enabling it. Scanner tests also clear a host PI_CODING_AGENT_SESSION_DIR so they never read real sessions. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/project/AgentSessionScanner.test.ts | 6 +++++- apps/web/src/components/onboarding/WelcomeWizard.tsx | 6 +++--- docs/user/welcome-wizard.md | 5 +++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 200c6a2667a08..3cbec21d8831d 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -76,6 +76,7 @@ const layerScannerTest = (input: ScannerTestInput) => value: input.piAgentDir ?? `${input.claudeHomePath}/pi-agent`, sensitive: false, }, + { name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false }, ], config: {}, }, @@ -638,7 +639,10 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { [ProviderInstanceId.make("pi")]: { driver: ProviderDriverKind.make("pi"), enabled: true, - environment: [{ name: "PI_CODING_AGENT_DIR", value: piAgentDir, sensitive: false }], + environment: [ + { name: "PI_CODING_AGENT_DIR", value: piAgentDir, sensitive: false }, + { name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false }, + ], config: {}, }, [ProviderInstanceId.make("pi-flat")]: { diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 6d258c6d9061d..4c768eb5763a4 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -1404,7 +1404,7 @@ function ImportStep({

- Looking for projects from Claude Code, Codex and Pi… + Looking for projects from Claude Code and Codex…

@@ -1419,7 +1419,7 @@ function ImportStep({ return ( {candidates.length > 0 ? (
@@ -1481,7 +1481,7 @@ function ImportStep({
) : scanCandidates.length === 0 ? (

- No existing Claude Code, Codex or Pi projects found. + No existing Claude Code or Codex projects found.

) : null} {scan.data?.truncated ? ( diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index 6e362a0629188..2d4b589d922fa 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -75,8 +75,9 @@ A large or malformed history can reach the scan limit. T3 Code keeps the projects it found and warns when projects or conversations may be missing. Imported projects include Claude, Codex and Pi conversations active within the last -30 days. You can continue those conversations in T3 Code. Pi sessions are read -from each enabled Pi provider's session directory, which follows +30 days. You can continue those conversations in T3 Code. Pi is off by default: +enable it in Settings, then open `/welcome` to import its sessions. Pi sessions +are read from each enabled Pi provider's session directory, which follows `PI_CODING_AGENT_SESSION_DIR` and `PI_CODING_AGENT_DIR` in its environment. A Pi conversation imports its active branch, the one Pi reopens.