Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ jobs:
runs-on: macos-latest
env:
NODE_ENV: development
PIDEX_BRANCH_PROOF_DIR: test-results/branch-proof
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4.2.1
with:
install: true
- run: bun install --frozen-lockfile
- run: bunx playwright-core install ffmpeg
- run: mise run test:e2e
- name: upload test artefacts
if: always()
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,17 @@ These are captures of real Electron interactions with a temporary Git workspace

- Add local workspaces and discover existing Pi sessions.
- Start, stop, steer, resume, and fork sessions; preview history before launching Pi.
- Browse linked session archives and branch entries; preview and continue the selected path.
- Render streamed Markdown, thinking, and expandable tool output.
- Browse files, review diffs, stage changes, and commit.
- Discover workspace and global skills, switch models, and save model and reasoning defaults per workspace.
- Use quick switch for workspaces, sessions, files, and commands.

Session history shows source/fork relationships and an entry tree with selected and archive-tip markers. Select a branch tip or an earlier entry to preview its parent chain. Resume at the archive tip continues in place; **Resume in new fork** continues a historical selection in a separate archive and preserves the source. **Fork from here** uses the selected entry. Pi 1.5.0 RPC does not expose arbitrary-entry navigation, so forks extract a native branch archive before launching Pi so even runtime startup writes preserve the source. Continuation requires an intact Pi v3 archive within the 16 MiB browsing limit. See [ADR 0013](docs/adr/0013-session-branch-selection.md) for runtime semantics and limits.

Active sessions and history use a virtualized transcript. A local development benchmark with 4,500 mixed entries opened in about 1.4 seconds, down from 8.3 seconds, and mounted fewer than 100 transcript DOM nodes instead of 39,000. Timings vary by machine and load; see [ADR 0011](docs/adr/0011-virtualized-transcript.md) for the method and limits.

Session trees, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues).
Recovery summaries and work-state signals, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues).

## Install

Expand Down
6 changes: 6 additions & 0 deletions docs/adr/0013-session-branch-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# ADR 0013 — Explicit archive branch selection over Pi RPC

- **Status**: Proposed
- **Context**: A Pi v3 JSONL archive is an append-only tree. Entries of every type have `id` and `parentId`; the session header is not a tree node. Reloading an archive selects the last non-header entry. Fork archives link to their source with `parentSession`. Inspection of installed Pi 1.5.0 (`docs/rpc-commands.md`, `dist/modes/rpc/rpc-mode.js`, `dist/core/session-manager.js`) confirmed that RPC exposes `get_tree`, `fork`, and `clone`, but does not expose the SDK's `navigateTree`. `fork` branches before a previous user message, while `clone` duplicates the currently active path. Neither selects an arbitrary archived entry.
- **Decision**: Use a shared archive graph and explicit entry ID for preview and continuation. Resume at the archive tip opens the original through `--session`. Continuing a historical entry is visibly labeled **Resume in new fork**. All forks write a new native v3 archive with a fresh session ID, source `parentSession`, and only the selected root-to-entry chain; it retains entry IDs, message/tool records, model changes, labels, compaction references, and unknown entry fields. Open that archive through supported `--session`, then verify the runtime leaf with `get_entries` before accepting prompts. Pi can append model/thinking settings during startup, so accept only a verified linear chain of those setup entries after the selection; reject other movement. Starting Pi against the source before calling `clone` can therefore modify source bytes. Extracting before startup preserves the source for current-tip forks too. Archive extraction does not invoke Pi extension fork hooks. Enforce one live runtime per archive in the main process; refresh runtime state and validate explicit history selections before reusing its existing tab. Running or mismatched selections fail without starting a competing writer. Never rewrite the source or invent a navigation RPC. Group linked fork archives in the workspace's session tree and display selected entry and archive tip separately. Entry lists and transcripts stay virtualized for large archives.
- **Consequences**: Historical continuation creates a separate session rather than moving the original session's in-memory leaf. Old archive versions remain previewable where their lineage is readable; continuation requires intact v3 input and tells the user to migrate older files in Pi. Missing parents, cycles, duplicate IDs, malformed records and stale selections fail closed for affected operations. Archive browsing has a 16 MiB bound. Preview shows stored conversation history along the selected parent chain, including pre-compaction messages; Pi constructs its own compacted runtime context when it opens the resulting archive. Cross-folder lineage migration and multi-root workflows remain follow-ups. A future documented navigation RPC can replace extraction after its exact-entry semantics are verified.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ Each ADR follows this shape:
| [0010](0010-distribution-channels.md) | Ship desktop installers and public package channels | Accepted |
| [0011](0011-virtualized-transcript.md) | Virtualize the session transcript | Accepted |
| [0012](0012-release-please-calver.md) | Release Please with a CalVer adapter | Accepted |
| [0013](0013-session-branch-selection.md) | Explicit archive branch selection over Pi RPC | Proposed |
145 changes: 113 additions & 32 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
forkArchive,
loadArchive,
selectedPath,
runtimeMatchesSelection,
} from './session-tree';
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import fs from 'fs';
import path from 'path';
Expand Down Expand Up @@ -104,20 +110,116 @@ ipcMain.handle('pidex:sessions', (_e, dirName: string) => {

ipcMain.handle(
'pidex:session/open',
async (_e, req: { cwd: string; sessionFile?: string; fork?: boolean }) => {
async (
_e,
req: {
cwd: string;
sessionFile?: string;
entryId?: string | null;
fork?: boolean;
},
) => {
const cwd = typeof req?.cwd === 'string' ? req.cwd : '';
const sessionFile =
let sessionFile =
typeof req?.sessionFile === 'string' ? req.sessionFile : undefined;
if (!cwd || !fs.existsSync(cwd)) {
return { error: 'workspace path does not exist' };
}
let expectedEntryId: string | null | undefined;
let extractedFork = false;
if (sessionFile) {
try {
await sessionPath(sessionFile);
} catch {
sessionFile = await sessionPath(sessionFile);
const archive = await loadArchive(sessionFile);
if (!archive.header || archive.header.version !== 3 || archive.damaged)
throw new Error(
'Continuing requires an intact Pi v3 archive. Open older archives in Pi to migrate them first.',
);
if (
req.entryId !== undefined &&
req.entryId !== null &&
typeof req.entryId !== 'string'
)
throw new Error('Invalid selected entry.');
const selected = selectedPath(archive, req.entryId).at(-1)?.id;
expectedEntryId = selected ?? null;
if (req.fork && !selected)
throw new Error('This session has no entry to fork.');
if (selected && (req.fork || selected !== archive.entries.at(-1)?.id)) {
sessionFile = await forkArchive(sessionFile, selected);
extractedFork = true;
}
} catch (error) {
return {
error:
error instanceof Error ? error.message : 'Session unavailable.',
};
}
}

// Archive ownership is authoritative here, rather than inferred from a
// renderer snapshot that becomes stale as soon as a prompt advances Pi.
async function readRuntime(runtime: RpcSession, reused = false) {
const state = await runtime.getState();
if (!state.success || runtime.launchError())
return {
error: 'session file outside pi sessions root or unavailable',
error:
state.error ??
runtime.launchError() ??
'Could not read Pi session state.',
};
if (sessionFile && runtime.archiveFile !== sessionFile)
return {
error:
'The open runtime switched archives. Select this archive again.',
};
if (
reused &&
(state.data as { isStreaming?: boolean } | undefined)?.isStreaming
)
return {
error:
'This archive is running in an open tab. Stop it before resuming, or fork this selection.',
};
if (expectedEntryId !== undefined) {
const position = await runtime.command({
type: 'get_entries',
...(expectedEntryId ? { since: expectedEntryId } : {}),
});
if (
!position.success ||
!runtimeMatchesSelection(expectedEntryId, position.data)
)
return {
error:
position.error ??
'The runtime branch moved since selection. Refresh history and select the branch again, or fork this entry.',
};
}
const messages = await runtime.getMessages();
if (!messages.success)
return { error: messages.error ?? 'Could not read Pi history.' };
return {
sessionId: runtime.id,
state: state.data,
messages:
(messages.data as { messages?: unknown[] } | undefined)?.messages ??
[],
};
}
if (sessionFile && !extractedFork) {
for (const runtime of sessions.values()) {
// Extension commands can switch a runtime's archive. Refresh its real
// state before deciding whether this file already has a live owner.
const state = await runtime.getState();
if (!state.success) {
if (runtime.launchError()) continue;
return {
error: `Could not verify archive ownership. Close the unresponsive session tab before resuming. ${state.error ?? ''}`,
};
}
if (runtime.archiveFile === sessionFile)
return readRuntime(runtime, true);
}
}

Expand All @@ -131,22 +233,12 @@ ipcMain.handle(
}
session.onEvent = (sessionId, event) =>
sendToRenderer('pidex:session:event', { sessionId, event });
session.onExit = (sessionId) =>
session.onExit = (sessionId) => {
sessions.delete(sessionId);
sendToRenderer('pidex:session:exit', { sessionId });
};
sessions.set(id, session);

if (req.fork && sessionFile) {
const cloned = await session.command({ type: 'clone' });
if (
!cloned.success ||
(cloned.data as { cancelled?: boolean } | undefined)?.cancelled
) {
sessions.delete(id);
session.dispose();
return { error: cloned.error ?? 'Session fork was cancelled.' };
}
}

if (!sessionFile) {
const defaults = await sessionDefaults(cwd);
if (defaults.model && defaults.provider) {
Expand Down Expand Up @@ -174,23 +266,12 @@ ipcMain.handle(
}
}

const [state, messages] = await Promise.all([
session.getState(),
session.getMessages(),
]);
const launchError = session.launchError();
if (launchError) {
const result = await readRuntime(session);
if ('error' in result) {
sessions.delete(id);
session.dispose();
return { error: launchError };
}
return {
sessionId: id,
state: state.success ? state.data : null,
messages: messages.success
? ((messages.data as { messages?: unknown[] })?.messages ?? [])
: [],
};
return result;
},
);

Expand Down
40 changes: 38 additions & 2 deletions src/main/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
class RpcSession {
readonly id: string;
readonly cwd: string;
archiveFile?: string;
private proc: ChildProcess;
private buffer = '';
private seq = 0;
Expand All @@ -91,6 +92,7 @@ class RpcSession {
) {
this.id = id;
this.cwd = cwd;
this.archiveFile = sessionFile;
const bin = resolvePi();
if (!bin) throw new Error(PI_MISSING);
const args = ['--mode', 'rpc'];
Expand Down Expand Up @@ -204,8 +206,42 @@ class RpcSession {
return this.closed ? this.failed : null;
}

getState() {
return this.command({ type: 'get_state' });
async getState() {
const response = await this.command({ type: 'get_state' });
const file = (response.data as { sessionFile?: unknown } | undefined)
?.sessionFile;
if (
response.success &&
this.archiveFile &&
(typeof file !== 'string' || !file)
)
return {
...response,
success: false,
error: 'Pi did not return its active archive path.',
};
if (response.success && typeof file === 'string') {
try {
this.archiveFile = fs.realpathSync(file);
} catch {
try {
this.archiveFile = path.join(
fs.realpathSync(path.dirname(file)),
path.basename(file),
);
} catch {
this.archiveFile = path.resolve(file);
}
}
}
if (response.success && typeof file === 'string')
response.data = {
...(response.data as Record<string, unknown>),
sessionFile: this.archiveFile,
};
if (response.success && typeof file !== 'string')
this.archiveFile = undefined;
return response;
}

getMessages() {
Expand Down
Loading
Loading