diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index f62be5b1e4..31cc924cd7 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -26,7 +26,7 @@ import type { Logger } from "../logger"; import type { PersistenceOutbox } from "../persistence-outbox"; import type { PluginRuntime } from "../plugin-runtime"; import { readSessionCollaboration } from "../services/session-collaboration"; -import { searchSessionsAcrossSources } from "../services/session-search"; +import { localNativeProjectPaths, searchSessionsAcrossSources } from "../services/session-search"; import type { IpcRegistrar } from "./types"; import { getActiveRemoteHostsBoot } from "../bootstrap/remote-hosts.js"; @@ -203,13 +203,16 @@ export function registerSessionIpc({ }); handle(IPC.invoke.sessionList, async () => { if (!host) throw new Error("host unavailable"); - const [result, native, { providers, defaults }] = await Promise.all([ + const [projectPaths, result, { providers, defaults }] = await Promise.all([ + localNativeProjectPaths(host), host.call<{ sessions: RuntimeSession[] }>("session.list"), - sidecar - ? sidecar.call<{ sessions: RuntimeSession[] }>("native.session.list").catch(() => ({ sessions: [] })) - : Promise.resolve({ sessions: [] }), sessionCapabilityContext(), ]); + const native = sidecar + ? await sidecar + .call<{ sessions: RuntimeSession[] }>("native.session.list", { projectPaths }) + .catch(() => ({ sessions: [] as RuntimeSession[] })) + : { sessions: [] as RuntimeSession[] }; return { ...result, sessions: [ diff --git a/apps/desktop/electron/main/live-voice/live-work-backend-port.ts b/apps/desktop/electron/main/live-voice/live-work-backend-port.ts index 66e2faa1db..0d89440831 100644 --- a/apps/desktop/electron/main/live-voice/live-work-backend-port.ts +++ b/apps/desktop/electron/main/live-voice/live-work-backend-port.ts @@ -11,6 +11,7 @@ import { DESKTOP_PRINCIPAL } from "../agent-host-bridge"; import type { BackendRouter, RemoteBackend } from "../remote/backend-router"; import type { RemoteHostsBoot } from "../bootstrap/remote-hosts"; import type { LiveWorkAdmission, LiveWorkPort, WorkSnapshot } from "@pi-desktop/host-runtime"; +import { localNativeProjectPaths } from "../services/session-search"; export type LiveWorkSessionRecord = { id: string; @@ -55,13 +56,14 @@ export async function listLiveWorkSessions(input: { if (!host) throw Object.assign(new Error("Local Host is unavailable"), { errorCode: "LIVE_WORK_NOT_READY" }); const sidecar = input.getSidecar(); const remoteHosts = input.getRemoteHosts(); - const [desktopResult, nativeResult, remoteResult] = await Promise.all([ + const [projectPaths, desktopResult, remoteResult] = await Promise.all([ + localNativeProjectPaths(host), host.call<{ sessions?: Array> }>("session.list"), - sidecar - ? sidecar.call<{ sessions?: Array> }>("native.session.list") - : Promise.resolve({ sessions: [] as Array> }), remoteHosts ? remoteHosts.listSessions() : Promise.resolve([]), ]); + const nativeResult = sidecar + ? await sidecar.call<{ sessions?: Array> }>("native.session.list", { projectPaths }) + : { sessions: [] as Array> }; const desktop: LiveWorkSessionRecord[] = (desktopResult.sessions ?? []).flatMap((session) => { if (typeof session.id !== "string" || !session.id.trim() || (session.source !== undefined && session.source !== "desktop")) return []; return [{ diff --git a/apps/desktop/electron/main/services/session-search.ts b/apps/desktop/electron/main/services/session-search.ts index 72be6d555d..1f5110d3bf 100644 --- a/apps/desktop/electron/main/services/session-search.ts +++ b/apps/desktop/electron/main/services/session-search.ts @@ -5,6 +5,34 @@ type RpcClient = { }; const SEARCH_PAGE_SIZE = 30; +const MAX_NATIVE_PROJECT_PATHS = 500; + +type NativeProjectHost = { + call(method: string, params?: unknown): Promise; +}; + +/** Local project paths whose Pi `sessionDir` native discovery must honor. */ +export async function localNativeProjectPaths(host: NativeProjectHost): Promise { + try { + const listed = (await host.call("projects.list")) as { projects?: Array<{ path?: unknown }> } | null; + const projects = + listed && typeof listed === "object" && Array.isArray(listed.projects) ? listed.projects : []; + const paths: string[] = []; + const seen = new Set(); + for (const project of projects) { + if (!project || typeof project.path !== "string") continue; + const path = project.path.trim(); + if (!path || path.startsWith("remote-project:") || seen.has(path)) continue; + seen.add(path); + paths.push(path); + if (paths.length >= MAX_NATIVE_PROJECT_PATHS) break; + } + return paths; + } catch (error) { + console.warn("[session-search] projects.list failed; native sessionDir discovery skipped", error); + return []; + } +} function compareHits( left: SessionSearchPage["hits"][number], @@ -41,10 +69,12 @@ export async function searchSessionsAcrossSources( const offset = Number.isInteger(rawOffset) ? Math.max(0, rawOffset) : 0; const requiredHostRows = offset + SEARCH_PAGE_SIZE; + const projectPaths = await localNativeProjectPaths(host); const nativeSearch = sidecar ? sidecar .call("native.session.search", { query: Reflect.get(request, "query"), + projectPaths, }) .catch(() => ({ hits: [], nextOffset: null })) : Promise.resolve({ hits: [], nextOffset: null }); diff --git a/apps/desktop/test/live-work-scope.test.mjs b/apps/desktop/test/live-work-scope.test.mjs index 6bbd0d312f..84077db966 100644 --- a/apps/desktop/test/live-work-scope.test.mjs +++ b/apps/desktop/test/live-work-scope.test.mjs @@ -283,11 +283,28 @@ test("fresh live work catalog resolves Composer targets from all backends and re }); t.after(() => server.close()); const { listLiveWorkSessions, requireLiveWorkSession } = await server.ssrLoadModule("/electron/main/live-voice/live-work-backend-port.ts"); + const sidecarCalls = []; const sessions = await listLiveWorkSessions({ - getHost: () => ({ call: async () => ({ sessions: [{ id: "desktop-1", source: "desktop", title: "Desktop", projectId: "project-1", projectPath: "/work/demo" }] }) }), - getSidecar: () => ({ call: async () => ({ sessions: [{ id: "native-pi:1", title: "Native", capabilities: { canPrompt: true } }] }) }), + getHost: () => ({ + call: async (method) => { + if (method === "projects.list") { + return { projects: [{ path: "/work/demo" }, { path: " " }, { path: "remote-project:office" }] }; + } + return { sessions: [{ id: "desktop-1", source: "desktop", title: "Desktop", projectId: "project-1", projectPath: "/work/demo" }] }; + }, + }), + getSidecar: () => ({ + call: async (method, input) => { + sidecarCalls.push({ method, input }); + return { sessions: [{ id: "native-pi:1", title: "Native", capabilities: { canPrompt: true } }] }; + }, + }), getRemoteHosts: () => ({ listSessions: async () => [{ id: "remote-1", title: "Remote", hostLabel: "Office", workspaceLabel: "repo", mode: "agent" }] }), }); + assert.deepEqual(sidecarCalls, [{ + method: "native.session.list", + input: { projectPaths: ["/work/demo"] }, + }]); assert.equal(requireLiveWorkSession(sessions, "desktop-1").source, "desktop"); assert.equal(requireLiveWorkSession(sessions, "desktop-1").projectId, "project-1"); @@ -300,3 +317,48 @@ test("fresh live work catalog resolves Composer targets from all backends and re }); assert.throws(() => requireLiveWorkSession(sessions, "missing"), { errorCode: "LIVE_WORK_SESSION_UNAVAILABLE" }); }); + +test("live work attaches the desktop list before projects.list settles (#1732)", async (t) => { + const server = await createServer({ + root: fileURLToPath(new URL("..", import.meta.url)), + configFile: false, + server: { middlewareMode: true, hmr: false, ws: false }, + appType: "custom", + optimizeDeps: { noDiscovery: true, include: [] }, + }); + t.after(() => server.close()); + const { listLiveWorkSessions } = await server.ssrLoadModule("/electron/main/live-voice/live-work-backend-port.ts"); + let releaseProjects; + const projectsGate = new Promise((resolve) => { + releaseProjects = resolve; + }); + const rejections = []; + const onRejection = (error) => { + rejections.push(error); + }; + process.on("unhandledRejection", onRejection); + try { + const pending = listLiveWorkSessions({ + getHost: () => ({ + call: async (method) => { + if (method === "projects.list") return projectsGate; + if (method === "session.list") throw new Error("session list failed"); + return {}; + }, + }), + getSidecar: () => ({ call: async () => ({ sessions: [] }) }), + getRemoteHosts: () => ({ listSessions: async () => [] }), + }).then( + () => "resolved", + (error) => error, + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(rejections.map((error) => error.message), []); + releaseProjects({ projects: [] }); + const outcome = await pending; + assert.equal(outcome instanceof Error, true); + assert.match(outcome.message, /session list failed/); + } finally { + process.off("unhandledRejection", onRejection); + } +}); diff --git a/apps/desktop/test/native-pi-sessions.test.mjs b/apps/desktop/test/native-pi-sessions.test.mjs index 899b597c48..a6af9c6d44 100644 --- a/apps/desktop/test/native-pi-sessions.test.mjs +++ b/apps/desktop/test/native-pi-sessions.test.mjs @@ -48,7 +48,7 @@ const { register } = await import("node:module"); register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); const { IPC, isImageGenerationModel, imageGenerationBindings } = await import("@pi-desktop/shared"); const { registerAgentIpc } = await import("../electron/main/ipc/agent-ipc.ts"); -const { searchSessionsAcrossSources } = await import("../electron/main/services/session-search.ts"); +const { localNativeProjectPaths, searchSessionsAcrossSources } = await import("../electron/main/services/session-search.ts"); const { createEventsSlice } = await import("../src/stores/slices/events-slice.ts"); const { createTranscriptSlice } = await import("../src/stores/slices/transcript-slice.ts"); const { api } = await import("../src/lib/api.ts"); @@ -226,7 +226,7 @@ function forkHarness({ host, sidecar, activeTurns = new Map() }) { "@pi-desktop/shared": sharedForIpc, "../importers": { convertSession() {}, scanAllSources() {}, scanModelConfigs() {} }, "../services/session-collaboration": { readSessionCollaboration() {} }, - "../services/session-search": { searchSessionsAcrossSources }, + "../services/session-search": { localNativeProjectPaths, searchSessionsAcrossSources }, "../bootstrap/remote-hosts.js": { getActiveRemoteHostsBoot: () => null }, }); registerSessionIpc({ @@ -246,6 +246,7 @@ function forkHarness({ host, sidecar, activeTurns = new Map() }) { }); return { handle: handlers.get(IPC.invoke.sessionFork), + list: handlers.get(IPC.invoke.sessionList), search: handlers.get(IPC.invoke.sessionSearch), hostCalls, sidecarCalls, @@ -267,6 +268,9 @@ test("global session search merges native sidecar hits with Desktop results", as host: (calls) => ({ call: async (method, input) => { calls.push({ method, input }); + if (method === "projects.list") { + return { projects: [{ path: "/work/demo" }, { path: "remote-project:office" }] }; + } return { hits: [desktopHit], nextOffset: null }; }, }), @@ -279,11 +283,133 @@ test("global session search merges native sidecar hits with Desktop results", as }); const result = await search({ query: "side chat", offset: 0 }); assert.deepEqual(result.hits.map((hit) => hit.session.id), ["native-pi:child", "desktop-session"]); - assert.deepEqual(sidecarCalls, [{ method: "native.session.search", input: { query: "side chat" } }]); - assert.deepEqual(hostCalls, [{ - method: "search.sessions", - input: { query: "side chat", offset: 0, limit: 30 }, + assert.deepEqual(sidecarCalls, [{ + method: "native.session.search", + input: { query: "side chat", projectPaths: ["/work/demo"] }, + }]); + assert.deepEqual(hostCalls, [ + { method: "projects.list", input: undefined }, + { method: "search.sessions", input: { query: "side chat", offset: 0, limit: 30 } }, + ]); +}); + +test("session list forwards local project paths to native discovery (#1732)", async () => { + const { list, hostCalls, sidecarCalls } = forkHarness({ + host: (calls) => ({ + call: async (method, input) => { + calls.push({ method, input }); + if (method === "projects.list") { + return { + projects: [ + { path: "/work/demo" }, + { path: "/work/demo" }, + { path: "remote-project:office" }, + { path: " " }, + ], + }; + } + if (method === "session.list") { + return { sessions: [{ id: "desktop-1", updatedAt: "2026-09-14T00:00:01.000Z" }] }; + } + return {}; + }, + }), + sidecar: (calls) => ({ + call: async (method, input) => { + calls.push({ method, input }); + return { + sessions: [{ id: "native-pi:1", updatedAt: "2026-09-14T00:00:02.000Z", source: "pi-native" }], + }; + }, + }), + }); + const result = await list(); + assert.deepEqual(result.sessions.map((session) => session.id), ["native-pi:1", "desktop-1"]); + assert.deepEqual(sidecarCalls, [{ + method: "native.session.list", + input: { projectPaths: ["/work/demo"] }, }]); + assert.deepEqual(hostCalls.map((call) => call.method), ["projects.list", "session.list"]); +}); + +test("a failed projects.list keeps Desktop sessions and logs the skip (#1732)", async () => { + const warnings = []; + const original = console.warn; + console.warn = (...args) => { + warnings.push(args); + }; + try { + const { list, sidecarCalls } = forkHarness({ + host: () => ({ + call: async (method) => { + if (method === "projects.list") throw new Error("index down"); + if (method === "session.list") { + return { sessions: [{ id: "desktop-1", updatedAt: "2026-09-14T00:00:01.000Z" }] }; + } + return {}; + }, + }), + sidecar: (calls) => ({ + call: async (method, input) => { + calls.push({ method, input }); + return { sessions: [] }; + }, + }), + }); + const result = await list(); + assert.deepEqual(result.sessions.map((session) => session.id), ["desktop-1"]); + assert.deepEqual(sidecarCalls, [{ + method: "native.session.list", + input: { projectPaths: [] }, + }]); + assert.ok( + warnings.some((args) => String(args[0]).includes("projects.list failed")), + "expected a projects.list warning", + ); + } finally { + console.warn = original; + } +}); + +test("session list attaches host handlers before projects.list settles (#1732)", async () => { + let releaseProjects; + const projectsGate = new Promise((resolve) => { + releaseProjects = resolve; + }); + const rejections = []; + const onRejection = (error) => { + rejections.push(error); + }; + process.on("unhandledRejection", onRejection); + try { + const { list } = forkHarness({ + host: () => ({ + call: async (method) => { + if (method === "projects.list") return projectsGate; + if (method === "session.list") throw new Error("session list failed"); + return {}; + }, + }), + sidecar: (calls) => ({ + call: async (method, input) => { + calls.push({ method, input }); + return { sessions: [] }; + }, + }), + }); + const pending = list().then( + () => "resolved", + (error) => error, + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(rejections.map((error) => error.message), []); + releaseProjects({ projects: [] }); + const outcome = await pending; + assert.equal(outcome instanceof Error, true); + assert.match(outcome.message, /session list failed/); + } finally { + process.off("unhandledRejection", onRejection); + } }); test("native fork routes to the sidecar and never to the Desktop host", async () => { diff --git a/docs/adr/0254-native-pi-session-continuation.md b/docs/adr/0254-native-pi-session-continuation.md index 2f1bc90d52..dfce7b8fcd 100644 --- a/docs/adr/0254-native-pi-session-continuation.md +++ b/docs/adr/0254-native-pi-session-continuation.md @@ -11,7 +11,7 @@ PI-Desktop historically imports Pi sessions by flattening them into Desktop-owne ## Decision -1. The Node agent sidecar exposes a source-discriminated `pi-native` adapter. It discovers files below Pi's agent session directory, projects immutable byte snapshots through `SessionManager.inMemory`, and opens a persistent `SessionManager` only when a prompt is accepted. +1. The Node agent sidecar exposes a source-discriminated `pi-native` adapter. It discovers files below Pi's agent session directory and below each `sessionDir` Pi resolves from settings (see the amendment below), projects immutable byte snapshots through `SessionManager.inMemory`, and opens a persistent `SessionManager` only when a prompt is accepted. 2. Native identity is an opaque composite derived from canonical file path plus native header id. Paths never cross the preload boundary. Electron merges native summaries with Rust-owned Desktop summaries; Rust remains the exclusive SQLite and Desktop-transcript owner. 3. Native continuation uses `createAgentSession` with Pi `ModelRuntime`, `SettingsManager`, `DefaultResourceLoader`, saved provider/model/thinking, and the original `SessionManager`. There is no Desktop provider fallback and no `UiMessage` reconstruction for model context. 4. The first slice supports list, detail, refresh, text-only Agent prompt with no tools (`noTools: "all"`), stop, and renderer-local pin/archive. Built-in and extension model tools stay disabled until an explicit Desktop permission bridge is designed; this is not full Pi tool parity. Rename, delete, move, revision, Plan/Goal, queue, collaboration, attachment, and transcript rewrite operations are rejected; fork and side chat are added by the amendment below. @@ -86,3 +86,32 @@ registration and tab; the child remains a native session in the sidebar and in title/project search, and reopens as an ordinary conversation. A send while a native turn is running fails before the Desktop queue with a visible message and a preserved draft; native sessions still cannot enqueue. + +## Amendment: configured sessionDir discovery (2026-10-18) + +Pi writes a session into `sessionDir` when that setting is present in global +`~/.pi/agent/settings.json` or in `/.pi/settings.json`. Desktop history +must discover and open those files with the same resolution the CLI uses, in +addition to the default agent session root (`/sessions`). + +- The default root stays a recursive scan, so sessions written before a custom + directory remain visible. +- An absolute or `~` global `sessionDir` is scanned even when no project is + known. A relative value is resolved from each known local project working + directory, not from the Desktop process cwd. Remote project ids are ignored. + At most 500 local project paths are considered. +- Custom directories are scanned non-recursively, matching Pi's flat `*.jsonl` + layout. Sessions in subdirectories of a configured `sessionDir` are ignored + by that flat scan. A file is admitted only when its real path stays inside + the default root or a resolved `sessionDir`. Symlink escapes are dropped. + An in-directory symlink is not a second copy of its target. A nested file + is omitted only when the custom directory is outside the default root; a + directory inside that root is not scanned again, and the recursive default + walk can still list nested JSONL there. +- Detail, prompt, and fork authorize the file against the default root, the + session directories for the current project list, and any session directory + that already admitted a file. A later empty project list does not revoke + that directory. A fork still publishes the child beside its parent. Trust + and provider gates are unchanged and follow the session header cwd, not + which settings file pointed at the directory. Untrusted project resources + stay browseable and read-only. Ancestor trust counts as trusted. diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index df5d9d2aae..6d3f413bd1 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -1805,11 +1805,35 @@ migration is required. ## 12. Native Pi session authority (ADR 0254) -Native Pi v3 sessions under the Pi agent session root are a second, explicitly -source-discriminated transcript authority owned by the Node agent sidecar. They -are never inserted into SQLite and never copied to the Desktop transcript -directory. `session.list` merges their projections with Rust-owned -Desktop summaries, and `session.get` routes by the opaque `native-pi:` id. +Native Pi v3 sessions are a second, explicitly source-discriminated transcript +authority owned by the Node agent sidecar. They are never inserted into SQLite +and never copied to the Desktop transcript directory. `session.list` merges +their projections with Rust-owned Desktop summaries, and `session.get` routes +by the opaque `native-pi:` id. + +Discovery always walks the Pi agent session root recursively +(`/sessions`, default `~/.pi/agent/sessions`). It also scans, without +recursion, each `sessionDir` the pinned coding-agent `SettingsManager` resolves: +an absolute or `~` value from global settings, and the merged global-plus-project +setting for each known local project. Session files in a subdirectory of a +configured `sessionDir` are ignored by that scan. A relative path resolves +from that project's working directory, not from the Desktop process cwd. +Remote project ids are skipped, and at most 500 local project paths are +considered. A configured directory whose real path is the default root, or +is inside it, is not scanned again. A file is listed only when its real +path stays inside the default root or one of those directories. A symlink +that escapes is omitted, and an in-directory symlink alias of a session +file is not a second row. A nested file under a custom `sessionDir` is +omitted when that directory is outside the default root. A `sessionDir` inside the default root is not scanned again, and +the recursive default walk can still list nested JSONL there. Detail, prompt, +and fork authorize a file against the default root, the session directories for +the current project list, and any session directory that already admitted a +file in this process, so a later empty project list does not revoke an open of +a session that was already discovered. A child fork is still published beside +its parent. A project Desktop +has not yet recorded does not contribute a relative `sessionDir`. An absolute +global `sessionDir` is scanned even when no project is known. + Discovery deduplicates native files that share the same JSONL `header.id`, keeping the projection with the newest transcript `updatedAt`. The selected file retains its path-derived opaque session id; duplicate files are not diff --git a/docs/spec/05-security/01-security.md b/docs/spec/05-security/01-security.md index 9b404585c8..4bc397a949 100644 --- a/docs/spec/05-security/01-security.md +++ b/docs/spec/05-security/01-security.md @@ -358,8 +358,11 @@ host-core. They do not change the loopback-only rule above. Native session paths remain sidecar-private. Renderer-visible ids are opaque hashes of canonical path plus verified header id. Every discovery/open resolves -the real path below the configured Pi session root and revalidates header id and -cwd; path traversal and symlink escape are rejected. +the real path below an allowed Pi session root — the agent session directory, +or a `sessionDir` resolved from global or project settings — and revalidates +header id and cwd; path traversal and symlink escape are rejected. A relative +`sessionDir` is resolved only for a known local project. Custom directories are +not walked recursively. Writable continuation requires a mode-0600 cooperative PI-Desktop lease beside the session and full-byte identity checks before each SDK append. After an diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index fc3b8c2607..5f92cdb2b6 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -16696,6 +16696,30 @@ plugin-form fixtures in an isolated temporary directory at runtime. - **Specs:** runtime §12; storage §12; ADR 0254. - **Status:** Source-regression covered; full Desktop journey Draft. +### E2E-SESSION-native-pi-project-session-dir + +- **Preconditions:** A local project has `.pi/settings.json` with + `"sessionDir": ".pi/sessions"` and a valid native v3 JSONL in that directory. + The default Pi agent session root also contains a valid session. A nested + JSONL and a symlink that leaves `.pi/sessions` are present. A second fixture + sets an absolute global `sessionDir` and no project path. A third sets a + relative global `sessionDir`. +- **Steps:** Refresh the session list, open the project-local session, and fork + it. Repeat list and open for the absolute global directory. Refresh the + relative global fixture once with no known project and once after that + project is known, including a remote project id in the known set. +- **Expected:** The project-local session and the default-root session both + appear. The nested file and the escaping symlink do not. Opening the + project-local session returns its transcript. Fork publishes one new JSONL + beside the parent and does not rewrite the parent. The absolute global + directory is listed without a project path. The relative global directory is + listed only for the known local project. Remote project ids are ignored. + Untrusted project resources stay browseable and read-only. +- **Specs:** runtime §12; storage §12; security §12; ADR 0254. +- **Status:** Source-regression covered by + `packages/agent-runtime/src/native-pi-session.test.ts` (the project fixture + asserts `readOnlyReason: project-untrusted` and `canPrompt: false`) and + `apps/desktop/test/native-pi-sessions.test.mjs`; full Desktop journey Draft. ### E2E-SUBAGENT-ordered-model-fallback-preserves-work diff --git a/packages/agent-runtime/src/native-pi-session.test.ts b/packages/agent-runtime/src/native-pi-session.test.ts index 6bbed1ea95..4c3dd9395e 100644 --- a/packages/agent-runtime/src/native-pi-session.test.ts +++ b/packages/agent-runtime/src/native-pi-session.test.ts @@ -1,8 +1,8 @@ -import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent"; +import { AgentSession, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent"; import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; import { NativePiSessionService } from "./native-pi-session.js"; import { @@ -48,6 +48,20 @@ function fixture(options: { newline?: boolean } = {}) { return { root, agentDir, sessionRoot, project, file, text }; } +function sessionText(id: string, cwd: string, userText: string): string { + const entries = [ + { type: "session", version: 3, id, timestamp: "2026-09-14T00:00:00.000Z", cwd }, + { + type: "message", + id: `${id}-user`, + parentId: null, + timestamp: "2026-09-14T00:00:01.000Z", + message: { role: "user", content: [{ type: "text", text: userText }], timestamp: 1 }, + }, + ]; + return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`; +} + describe("NativePiSessionService", () => { it("lists and projects a native v3 tree without changing its bytes", async () => { const f = fixture(); @@ -61,6 +75,161 @@ describe("NativePiSessionService", () => { expect(readFileSync(f.file)).toEqual(before); }); + it("lists and opens sessions stored in a project sessionDir (#1732)", async () => { + const f = fixture(); + const customDir = join(f.project, ".pi", "sessions"); + mkdirSync(customDir, { recursive: true }); + writeFileSync( + join(f.project, ".pi", "settings.json"), + `${JSON.stringify({ sessionDir: ".pi/sessions" })}\n`, + ); + const localText = sessionText("local-id", f.project, "project local session"); + writeFileSync(join(customDir, "local.jsonl"), localText); + mkdirSync(join(customDir, "nested")); + writeFileSync( + join(customDir, "nested", "hidden.jsonl"), + sessionText("hidden-id", f.project, "nested hidden session"), + ); + const outside = join(f.root, "outside.jsonl"); + writeFileSync(outside, sessionText("outside-id", f.project, "outside session")); + symlinkSync(outside, join(customDir, "escape.jsonl")); + + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const sessions = await service.list({ projectPaths: [f.project] }); + const titles = sessions.map((session) => session.title); + expect(titles).toContain("project local session"); + expect(titles).not.toContain("nested hidden session"); + expect(titles).not.toContain("outside session"); + expect(titles).toContain("hello"); + + const local = sessions.find((session) => session.title === "project local session"); + expect(local?.projectPath).toBe(f.project); + expect(local?.readOnlyReason).toBe("project-untrusted"); + expect(local?.capabilities?.canPrompt).toBe(false); + const detail = service.detail(local!.id); + expect(detail?.messages.map((message) => message.content)).toEqual(["project local session"]); + expect(readFileSync(join(customDir, "local.jsonl"), "utf8")).toBe(localText); + + const forked = service.fork({ id: local!.id }); + expect(forked.source).toBe("pi-native"); + expect(forked.messages.map((message) => message.content)).toContain("project local session"); + const published = readdirSync(customDir).filter((name) => name.endsWith(".jsonl") && name !== "local.jsonl" && name !== "escape.jsonl"); + expect(published).toHaveLength(1); + expect(readFileSync(join(customDir, "local.jsonl"), "utf8")).toBe(localText); + }); + + it("lists sessions from an absolute global sessionDir without a project path (#1732)", async () => { + const f = fixture(); + const customDir = join(f.root, "global-sessions"); + mkdirSync(customDir); + writeFileSync(join(f.agentDir, "settings.json"), `${JSON.stringify({ sessionDir: customDir })}\n`); + const text = sessionText("global-id", f.project, "global session dir"); + writeFileSync(join(customDir, "global.jsonl"), text); + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const sessions = await service.list(); + expect(sessions.map((session) => session.title)).toContain("global session dir"); + const found = sessions.find((session) => session.title === "global session dir"); + expect(service.detail(found!.id)?.messages.map((message) => message.content)).toEqual(["global session dir"]); + expect(readFileSync(join(customDir, "global.jsonl"), "utf8")).toBe(text); + }); + + it("resolves a relative global sessionDir from each known project (#1732)", async () => { + const f = fixture(); + writeFileSync(join(f.agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" })}\n`); + const customDir = join(f.project, ".pi", "sessions"); + mkdirSync(customDir, { recursive: true }); + writeFileSync(join(customDir, "relative.jsonl"), sessionText("relative-id", f.project, "relative global session")); + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + expect((await service.list()).map((session) => session.title)).not.toContain("relative global session"); + const sessions = await service.list({ projectPaths: [f.project, "remote-project:host/path"] }); + expect(sessions.map((session) => session.title)).toContain("relative global session"); + }); + + it("lists one openable row when a custom sessionDir aliases a file inside itself (#1732)", async () => { + const f = fixture(); + const customDir = join(f.project, ".pi", "sessions"); + mkdirSync(customDir, { recursive: true }); + writeFileSync(join(f.project, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" })}\n`); + writeFileSync(join(customDir, "local.jsonl"), sessionText("local-id", f.project, "project local session")); + symlinkSync(join(customDir, "local.jsonl"), join(customDir, "latest.jsonl")); + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const sessions = await service.list({ projectPaths: [f.project] }); + const matches = sessions.filter((session) => session.title === "project local session"); + expect(matches).toHaveLength(1); + expect(new Set(matches.map((session) => session.id)).size).toBe(1); + expect(service.detail(matches[0].id)?.messages.map((message) => message.content)).toEqual(["project local session"]); + }); + + it("opens a project sessionDir session after a later empty project list (#1732)", async () => { + const f = fixture(); + const customDir = join(f.project, ".pi", "sessions"); + mkdirSync(customDir, { recursive: true }); + writeFileSync(join(f.project, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" })}\n`); + writeFileSync(join(customDir, "local.jsonl"), sessionText("local-id", f.project, "project local session")); + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const listed = await service.list({ projectPaths: [f.project] }); + const local = listed.find((session) => session.title === "project local session"); + expect(local).toBeTruthy(); + const again = await service.list({ projectPaths: [] }); + expect(again.map((session) => session.title)).not.toContain("project local session"); + expect(service.detail(local!.id)?.messages.map((message) => message.content)).toEqual(["project local session"]); + expect(service.fork({ id: local!.id }).messages.map((message) => message.content)).toContain("project local session"); + }); + + it("keeps interleaved list() calls on their own configured session dirs (#1732)", async () => { + const f = fixture(); + const projectB = join(f.root, "project-b"); + mkdirSync(projectB); + const dirA = join(f.project, ".pi", "sessions"); + const dirB = join(projectB, ".pi", "sessions"); + mkdirSync(dirA, { recursive: true }); + mkdirSync(dirB, { recursive: true }); + const settings = `${JSON.stringify({ sessionDir: ".pi/sessions" })}\n`; + writeFileSync(join(f.project, ".pi", "settings.json"), settings); + writeFileSync(join(projectB, ".pi", "settings.json"), settings); + writeFileSync(join(dirA, "a.jsonl"), sessionText("id-a", f.project, "session from project a")); + writeFileSync(join(dirB, "b.jsonl"), sessionText("id-b", projectB, "session from project b")); + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const original = SettingsManager.create; + let other: Promise>> | undefined; + let armInterleaving = true; + // Discovery reads settings before it walks project paths. A second list() + // that starts during that read must not replace the first call's roots. + vi.spyOn(SettingsManager, "create").mockImplementation((cwd, agentDir, options) => { + const manager = original.call(SettingsManager, cwd, agentDir, options); + if (armInterleaving) { + armInterleaving = false; + other = service.list({ projectPaths: [projectB] }); + } + return manager; + }); + + const fromA = await service.list({ projectPaths: [f.project] }); + const fromB = await other; + const titlesA = fromA.map((session) => session.title); + const titlesB = fromB?.map((session) => session.title) ?? []; + expect(titlesA).toContain("session from project a"); + expect(titlesA).not.toContain("session from project b"); + expect(titlesB).toContain("session from project b"); + expect(titlesB).not.toContain("session from project a"); + }); + + it("logs an unreadable sessionDir settings file once per path and still lists the default root (#1732)", async () => { + const f = fixture(); + writeFileSync(join(f.agentDir, "settings.json"), "{"); + mkdirSync(join(f.root, "other-project")); + const warnings: unknown[][] = []; + vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args); + }); + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const sessions = await service.list({ projectPaths: [f.project, join(f.root, "other-project")] }); + expect(sessions.map((session) => session.title)).toContain("hello"); + const settingsWarnings = warnings.filter((args) => String(args[0]).includes("sessionDir settings unreadable")); + expect(settingsWarnings).toHaveLength(1); + expect(String(settingsWarnings[0]?.[1])).toContain("settings.json"); + }); + it("collapses copied session files onto one entry per native id (#1359)", async () => { const f = fixture(); diff --git a/packages/agent-runtime/src/native-pi-session.ts b/packages/agent-runtime/src/native-pi-session.ts index f0c10807c3..931dba7143 100644 --- a/packages/agent-runtime/src/native-pi-session.ts +++ b/packages/agent-runtime/src/native-pi-session.ts @@ -91,6 +91,82 @@ function inside(root: string, candidate: string): boolean { return child === "" || (!child.startsWith("..") && !isAbsolute(child)); } +const MAX_NATIVE_PROJECT_PATHS = 500; + +function normalizeProjectPaths(paths: readonly string[] | undefined): string[] { + if (!paths) return []; + const seen = new Set(); + const normalized: string[] = []; + for (const value of paths) { + if (typeof value !== "string") continue; + const path = value.trim(); + if (!path || path.startsWith("remote-project:") || seen.has(path)) continue; + seen.add(path); + normalized.push(path); + if (normalized.length >= MAX_NATIVE_PROJECT_PATHS) break; + } + return normalized; +} + +function warnSessionDirErrors( + cwd: string, + errors: ReadonlyArray<{ path?: string; scope: string; error: unknown }>, + seen: Set, +): void { + for (const entry of errors) { + const key = entry.path ?? `${cwd}\0${entry.scope}`; + if (seen.has(key)) continue; + seen.add(key); + console.warn("[native-pi] sessionDir settings unreadable", key, entry.error); + } +} + +function rawSessionDir(cwd: string, agentDir: string, seen: Set): string | undefined { + try { + const manager = SettingsManager.create(cwd, agentDir); + warnSessionDirErrors(cwd, manager.drainErrors(), seen); + return manager.getSessionDir() || undefined; + } catch (error) { + const key = `throw\0${cwd}\0${agentDir}`; + if (!seen.has(key)) { + seen.add(key); + console.warn("[native-pi] sessionDir resolution failed", cwd, error); + } + return undefined; + } +} + +/** Absolute `sessionDir` from global settings. Relative values stay project-scoped. */ +function globalAbsoluteSessionDir(agentDir: string, seen: Set): string | undefined { + const raw = rawSessionDir(agentDir, agentDir, seen); + return raw && isAbsolute(raw) ? resolve(raw) : undefined; +} + +/** Pi resolves a relative `sessionDir` from the project working directory. */ +function projectSessionDir(projectPath: string, agentDir: string, seen: Set): string | undefined { + const raw = rawSessionDir(projectPath, agentDir, seen); + if (!raw) return undefined; + return isAbsolute(raw) ? resolve(raw) : resolve(projectPath, raw); +} + +/** + * Custom session directories are flat. Pi writes `*.jsonl` in `sessionDir` + * itself and does not encode a cwd subdirectory, so session files in + * subdirectories of a configured sessionDir are ignored. + */ +function topLevelJsonl(dir: string): string[] { + try { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + // `isFile()` is false for a symlink, same as the default-root walk. + if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(join(dir, entry.name)); + } + return files; + } catch { + return []; + } +} + function textContent(content: unknown): { text: string; thinking?: string } { if (typeof content === "string") return { text: content }; if (!Array.isArray(content)) return { text: "" }; @@ -466,6 +542,8 @@ export class NativePiSessionService { private readonly opening = new Set(); private modelRuntime?: ModelRuntime; private readonly modelRuntimeFactory: () => Promise; + /** Real directories that already admitted a file. Open and fork keep them after a later empty project list. */ + private readonly admittedRoots: string[] = []; constructor(options: { agentDir?: string; @@ -485,21 +563,79 @@ export class NativePiSessionService { })); } - async list(): Promise { - let root: string; + private configuredSessionDirs(projectPaths: readonly string[]): string[] { + const dirs: string[] = []; + const seen = new Set(); + const settingsErrors = new Set(); + const add = (dir: string | undefined) => { + if (!dir) return; + const resolved = resolve(dir); + if (seen.has(resolved)) return; + seen.add(resolved); + dirs.push(resolved); + }; + add(globalAbsoluteSessionDir(this.agentDir, settingsErrors)); + for (const projectPath of projectPaths) add(projectSessionDir(projectPath, this.agentDir, settingsErrors)); + return dirs; + } + + private sessionRoots(projectPaths: readonly string[] = []): string[] { + const roots: string[] = []; + const add = (dir: string) => { + try { + const real = realpathSync(dir); + if (!roots.includes(real)) roots.push(real); + } catch { + // A missing directory contributes no files and no containment root. + } + }; + add(this.root); + for (const dir of this.configuredSessionDirs(projectPaths)) add(dir); + for (const dir of this.admittedRoots) add(dir); + return roots; + } + + async list(options?: { projectPaths?: readonly string[] }): Promise { + // Kept local so two overlapping list() calls cannot swap each other's roots. + const projectPaths = normalizeProjectPaths(options?.projectPaths); + let defaultRoot: string | undefined; try { - root = realpathSync(this.root); + defaultRoot = realpathSync(this.root); } catch { - return []; + defaultRoot = undefined; + } + const extraRoots: string[] = []; + for (const dir of this.configuredSessionDirs(projectPaths)) { + try { + const real = realpathSync(dir); + if (defaultRoot && (real === defaultRoot || inside(defaultRoot, real))) continue; + if (!extraRoots.includes(real)) extraRoots.push(real); + } catch { + // The configured directory does not exist yet. + } + } + if (!defaultRoot && extraRoots.length === 0) return []; + const allowed = [...(defaultRoot ? [defaultRoot] : []), ...extraRoots]; + for (const root of allowed) { + if (!this.admittedRoots.includes(root)) this.admittedRoots.push(root); } + const seenCandidates = new Set(); + const candidates = [ + ...(defaultRoot ? walkJsonl(defaultRoot) : []), + ...extraRoots.flatMap((dir) => topLevelJsonl(dir)), + ].filter((candidate) => { + if (seenCandidates.has(candidate)) return false; + seenCandidates.add(candidate); + return true; + }); const modelRuntime = await this.modelRuntimeFactory().catch(() => undefined); this.modelRuntime = modelRuntime; const trustStore = new ProjectTrustStore(this.agentDir); const summaries = await Promise.all( - walkJsonl(root).map(async (candidate) => { + candidates.map(async (candidate) => { try { const path = realpathSync(candidate); - if (!inside(root, path) || lstatSync(path).isSymbolicLink()) return undefined; + if (!allowed.some((root) => inside(root, path)) || lstatSync(path).isSymbolicLink()) return undefined; const snap = nativePiSnapshot(path); const entries = parseSessionEntries(snap.bytes.toString("utf8")); const header = entries.find((entry) => entry.type === "session") as any; @@ -587,24 +723,31 @@ export class NativePiSessionService { const record = this.records.get(summary.id); if (!record) continue; if (seenNativeIds.has(record.nativeId)) { - this.records.delete(summary.id); + // Two directory names can resolve to one file and share this id. + // Deleting it would drop the winner while both rows stay listed. + if (!winnerIds.has(summary.id)) this.records.delete(summary.id); continue; } seenNativeIds.add(record.nativeId); winnerIds.add(summary.id); } - deduped.push(...summaries.filter((summary) => summary && winnerIds.has(summary.id))); + const emittedIds = new Set(); + deduped.push(...summaries.filter((summary) => { + if (!summary || !winnerIds.has(summary.id) || emittedIds.has(summary.id)) return false; + emittedIds.add(summary.id); + return true; + })); return deduped .filter((session): session is SessionSummary => Boolean(session)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } - async search(query: string): Promise { + async search(query: string, options?: { projectPaths?: readonly string[] }): Promise { const normalized = query.trim(); if (!normalized) return { hits: [], nextOffset: null }; if (normalized.length > 500) throw new Error("query exceeds 500 characters"); - const sessions = await this.list(); + const sessions = await this.list(options); const hits: SessionSearchPage["hits"] = []; for (const session of sessions) { const record = this.records.get(session.id); @@ -809,8 +952,8 @@ export class NativePiSessionService { let published: CompleteOwnedFile | undefined; try { const directory = realpathSync(dirname(record.path)); - const root = realpathSync(this.root); - if (!inside(root, directory)) { + const roots = this.sessionRoots(); + if (!roots.some((root) => inside(root, directory))) { throw Object.assign(new Error("Native Pi session path is outside the allowed root"), { errorCode: "PERMISSION_DENIED", }); @@ -904,7 +1047,7 @@ export class NativePiSessionService { throw new Error("Native Pi fork produced an invalid session file"); } const resolvedChild = realpathSync(childPath); - if (resolvedChild !== childPath || !inside(root, resolvedChild)) { + if (resolvedChild !== childPath || !roots.some((root) => inside(root, resolvedChild))) { throw Object.assign(new Error("Native Pi fork path is outside the session root"), { errorCode: "PERMISSION_DENIED", }); @@ -1056,9 +1199,8 @@ export class NativePiSessionService { if (!record) { throw Object.assign(new Error("Native Pi session not found; refresh sessions and try again"), { errorCode: "NOT_FOUND" }); } - const root = realpathSync(this.root); const path = realpathSync(record.path); - if (!inside(root, path) || lstatSync(path).isSymbolicLink()) { + if (!this.sessionRoots().some((root) => inside(root, path)) || lstatSync(path).isSymbolicLink()) { throw Object.assign(new Error("Native Pi session path is outside the allowed root"), { errorCode: "PERMISSION_DENIED" }); } return { ...record, path }; diff --git a/packages/agent-runtime/src/sidecar.ts b/packages/agent-runtime/src/sidecar.ts index c591dabaf4..1fdcd50f24 100644 --- a/packages/agent-runtime/src/sidecar.ts +++ b/packages/agent-runtime/src/sidecar.ts @@ -62,6 +62,11 @@ async function getNativePiService() { return nativePiServiceFactory(); } +function nativeProjectPaths(params: { projectPaths?: unknown } | undefined): string[] { + const value = params?.projectPaths; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + const runtimes: RuntimeMap = new Map(); const hostProxy = new ParentHostProxy(); const testRuntimeIds = new WeakMap(); @@ -396,11 +401,13 @@ async function handle(method: string, params: any): Promise { return { ok: true, runtimes: runtimes.size }; case "native.session.list": { const service = await getNativePiService(); - return { sessions: await service.list() }; + return { sessions: await service.list({ projectPaths: nativeProjectPaths(params) }) }; } case "native.session.search": { const service = await getNativePiService(); - return service.search(String(params.query ?? "")); + return service.search(String(params?.query ?? ""), { + projectPaths: nativeProjectPaths(params), + }); } case "native.session.get": { const service = await getNativePiService();