Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
aa4372c
Add sessions commands
jverneaut Mar 11, 2026
880e56e
Persist AI sessions as JSONL in date-based appdata subfolders
jverneaut Mar 11, 2026
b2f5ef4
Add sessions resuming capability
jverneaut Mar 11, 2026
91feb84
Implement sessions listing command
jverneaut Mar 11, 2026
7a0fedf
Save tool progress to sessions
jverneaut Mar 11, 2026
def97f9
Add support for "latest" argument in sessions resume
jverneaut Mar 11, 2026
3b5776e
Implement sessions delete command
jverneaut Mar 11, 2026
d358405
Add timestamp to sessions filename
jverneaut Mar 11, 2026
63026e7
Add sessions browser for agument-less delete and resume commands
jverneaut Mar 11, 2026
2b2fc5f
Add additional ai and ai-sessions tests cases
jverneaut Mar 11, 2026
60b9c62
Add a --no-session-persistence flag
jverneaut Mar 11, 2026
be02222
Move ai command to ai subfolder
jverneaut Mar 12, 2026
425e676
Separate ai and ai sessions commands registration into separate files
jverneaut Mar 12, 2026
8aeb946
Move sessions from lib to ai folder
jverneaut Mar 12, 2026
ec7b31a
Move test files inside ai command tests folder
jverneaut Mar 12, 2026
f9174b7
Separate sessions commands into separate files
jverneaut Mar 12, 2026
322a835
Separate sessions into submodules
jverneaut Mar 12, 2026
1573e19
Restore sessions feature after rebase
jverneaut Mar 12, 2026
971e611
Stop differentiating assistant.message and tool.result
jverneaut Mar 12, 2026
cbaa188
Fix blinking dots in resume output
jverneaut Mar 12, 2026
3c34c7c
Save session context (cwd, model, provider)
jverneaut Mar 12, 2026
c644734
Extract sessions filename related methods to file-naming.ts
jverneaut Mar 13, 2026
6f77f78
Clarify ai sessions path helpers
jverneaut Mar 13, 2026
7d4c2d3
Restore _activeSiteData from _activeSite when not set
jverneaut Mar 13, 2026
ed5de4b
Ensure sessions are only recorded when persist is called
jverneaut Mar 13, 2026
59acf9e
Update AI sessions commands registration
jverneaut Mar 17, 2026
2de5ec1
Use truncateToWidth and visibleWidth from pi-tui
jverneaut Mar 17, 2026
9fab115
Stop persisting cwd in session context
jverneaut Mar 17, 2026
f59b43a
Save remote and url to site.selected event
jverneaut Mar 18, 2026
ffb4ad9
Ensure selected model after /model command is persisted before sendin…
jverneaut Mar 19, 2026
19ff5a6
Use site.selected as the canonical source of truth for site selection
jverneaut Mar 19, 2026
3a955d1
Remove unused cwd type
jverneaut Mar 20, 2026
08a1f9e
Persist session context on provider switch
jverneaut Mar 20, 2026
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
57 changes: 57 additions & 0 deletions apps/cli/ai/sessions/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { AI_MODELS, type AiModelId } from 'cli/ai/agent';
import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers';
import type { LoadedAiSession } from './types';

function isAiProviderId( value: string ): value is AiProviderId {
return Object.prototype.hasOwnProperty.call( AI_PROVIDERS, value );
}

function isAiModelId( value: string ): value is AiModelId {
return Object.prototype.hasOwnProperty.call( AI_MODELS, value );
}

export interface ResumeSessionContext {
sessionId?: string;
provider?: AiProviderId;
model?: AiModelId;
}

export function resolveResumeSessionContext(
resumeSession?: LoadedAiSession
): ResumeSessionContext {
if ( ! resumeSession ) {
return {};
}

const context: ResumeSessionContext = {};
const linkedSessionId = resumeSession.summary.agentSessionId?.trim();
if ( linkedSessionId ) {
context.sessionId = linkedSessionId;
}

for ( let index = resumeSession.events.length - 1; index >= 0; index -= 1 ) {
const event = resumeSession.events[ index ];

if ( ! context.sessionId && event.type === 'sdk.message' ) {
const sessionId = ( event.message as { session_id?: unknown } ).session_id;
if ( typeof sessionId === 'string' && sessionId.trim().length > 0 ) {
context.sessionId = sessionId;
}
}

if ( event.type === 'session.context' ) {
if ( ! context.provider && isAiProviderId( event.provider ) ) {
context.provider = event.provider;
}
if ( ! context.model && isAiModelId( event.model ) ) {
context.model = event.model;
}
}

if ( context.sessionId && context.provider && context.model ) {
break;
}
}

return context;
}
37 changes: 37 additions & 0 deletions apps/cli/ai/sessions/file-naming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import path from 'path';

const SESSION_FILE_EXTENSION = '.jsonl';
const SESSION_ID_REGEX = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;

/**
* Session files follow this stable naming contract:
* `<iso8601-filename-timestamp>-<session-uuid>.jsonl`
*
* Example:
* `2026-03-11T10-00-00-8b4cead7-3f8b-4727-86cd-a495c055850a.jsonl`
*
* Why this format:
* - The ISO-8601-derived timestamp prefix keeps files lexicographically sortable by start time.
* - `:` is replaced with `-` so filenames are valid on Windows.
* - UUID suffix guarantees uniqueness for sessions started in the same second.
*/
export function buildAiSessionFileName( startedAt: Date, sessionId: string ): string {
const sortableTimestamp = startedAt
.toISOString()
.replace( /:/g, '-' )
.replace( /\.\d{3}Z$/, '' );
return `${ sortableTimestamp }-${ sessionId }${ SESSION_FILE_EXTENSION }`;
}

/**
* Reads the canonical session id from a session file path produced by
* `buildAiSessionFileName()`.
*
* If the file does not match the expected contract, we fall back to the bare
* filename (without extension) so older/unexpected files remain listable.
*/
export function extractAiSessionIdFromFilePath( filePath: string ): string {
const fileName = path.basename( filePath, SESSION_FILE_EXTENSION );
const uuidMatch = fileName.match( SESSION_ID_REGEX );
return uuidMatch?.[ 1 ] ?? fileName;
}
19 changes: 19 additions & 0 deletions apps/cli/ai/sessions/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import path from 'path';
import { getAppdataDirectory } from 'cli/lib/appdata';

// Keep month/day segments zero-padded so directory names sort chronologically.
function formatDatePart( value: number ): string {
return String( value ).padStart( 2, '0' );
}

export function getAiSessionsRootDirectory(): string {
return path.join( getAppdataDirectory(), 'sessions' );
}

// Bucket sessions by local calendar date: <root>/<YYYY>/<MM>/<DD>.
export function getAiSessionsDirectoryForDate( date: Date ): string {
const year = String( date.getFullYear() );
const month = formatDatePart( date.getMonth() + 1 );
const day = formatDatePart( date.getDate() );
return path.join( getAiSessionsRootDirectory(), year, month, day );
}
163 changes: 163 additions & 0 deletions apps/cli/ai/sessions/recorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import crypto from 'crypto';
import fs from 'fs/promises';
import path from 'path';
import { buildAiSessionFileName } from './file-naming';
import { getAiSessionsDirectoryForDate } from './paths';
import type { AiSessionEvent, TurnStatus } from './types';
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AiModelId } from 'cli/ai/agent';
import type { AiProviderId } from 'cli/ai/providers';

function toIsoTimestamp( value?: Date ): string {
return ( value ?? new Date() ).toISOString();
}

export class AiSessionRecorder {
public readonly sessionId: string;
public readonly filePath: string;

private linkedAgentSessionIds = new Set< string >();

private constructor( sessionId: string, filePath: string, linkedAgentSessionIds: string[] = [] ) {
this.sessionId = sessionId;
this.filePath = filePath;
this.linkedAgentSessionIds = new Set( linkedAgentSessionIds );
}

static async create( options: { startedAt?: Date } = {} ): Promise< AiSessionRecorder > {
const startedAt = options.startedAt ?? new Date();
const sessionId = crypto.randomUUID();
const directory = getAiSessionsDirectoryForDate( startedAt );
const fileName = buildAiSessionFileName( startedAt, sessionId );
const filePath = path.join( directory, fileName );

await fs.mkdir( directory, { recursive: true } );

const recorder = new AiSessionRecorder( sessionId, filePath );
await recorder.appendEvent( {
type: 'session.started',
timestamp: toIsoTimestamp( startedAt ),
version: 1,
sessionId,
} );

return recorder;
}

static async open( options: {
sessionId: string;
filePath: string;
linkedAgentSessionIds?: string[];
} ): Promise< AiSessionRecorder > {
await fs.access( options.filePath );
return new AiSessionRecorder(
options.sessionId,
options.filePath,
options.linkedAgentSessionIds ?? []
);
}

async recordAgentSessionId( agentSessionId: string ): Promise< void > {
if ( this.linkedAgentSessionIds.has( agentSessionId ) ) {
return;
}

this.linkedAgentSessionIds.add( agentSessionId );
await this.appendEvent( {
type: 'session.linked',
timestamp: toIsoTimestamp(),
agentSessionId,
} );
}

async recordSessionContext( options: {
provider: AiProviderId;
model: AiModelId;
} ): Promise< void > {
await this.appendEvent( {
type: 'session.context',
timestamp: toIsoTimestamp(),
provider: options.provider,
model: options.model,
} );
}

async recordSiteSelected( site: {
name: string;
path: string;
remote?: boolean;
url?: string;
} ): Promise< void > {
await this.appendEvent( {
type: 'site.selected',
timestamp: toIsoTimestamp(),
siteName: site.name,
sitePath: site.path,
remote: site.remote,
url: site.url,
} );
}

async recordUserMessage( options: {
text: string;
source: 'prompt' | 'ask_user';
sitePath?: string;
} ): Promise< void > {
await this.appendEvent( {
type: 'user.message',
timestamp: toIsoTimestamp(),
text: options.text,
source: options.source,
sitePath: options.sitePath,
} );
}

async recordSdkMessage( message: SDKMessage, timestamp?: string ): Promise< void > {
await this.appendEvent( {
type: 'sdk.message',
timestamp: timestamp ?? toIsoTimestamp(),
message,
} );
}

async recordToolProgress( message: string, timestamp?: string ): Promise< void > {
if ( ! message.trim() ) {
return;
}

await this.appendEvent( {
type: 'tool.progress',
timestamp: timestamp ?? toIsoTimestamp(),
message,
} );
}

async recordAgentQuestion( options: {
question: string;
options: Array< {
label: string;
description: string;
} >;
} ): Promise< void > {
await this.appendEvent( {
type: 'agent.question',
timestamp: toIsoTimestamp(),
question: options.question,
options: options.options,
} );
}

async recordTurnClosed( status: TurnStatus ): Promise< void > {
await this.appendEvent( {
type: 'turn.closed',
timestamp: toIsoTimestamp(),
status,
} );
}

private async appendEvent( event: AiSessionEvent ): Promise< void > {
await fs.appendFile( this.filePath, `${ JSON.stringify( event ) }\n`, {
encoding: 'utf8',
} );
}
}
70 changes: 70 additions & 0 deletions apps/cli/ai/sessions/replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { AiChatUI } from 'cli/ai/ui';
import type { AiSessionEvent } from './types';

export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): void {
ui.prepareForReplay();
let isTurnOpen = false;

try {
for ( const event of events ) {
ui.setReplayTimestamp( event.timestamp );

if ( event.type === 'site.selected' ) {
ui.setActiveSite(
{
name: event.siteName,
path: event.sitePath,
running: false,
remote: event.remote === true,
url: typeof event.url === 'string' ? event.url : undefined,
},
{ announce: true, emitEvent: false }
);
continue;
}

if ( event.type === 'user.message' ) {
if ( event.source === 'ask_user' ) {
continue;
}

// Defensive close if the previous turn never emitted turn.closed.
if ( isTurnOpen ) {
ui.endAgentTurn();
}

ui.beginAgentTurn();
isTurnOpen = true;
ui.addUserMessage( event.text );
continue;
}

if ( event.type === 'sdk.message' ) {
ui.handleMessage( event.message );
continue;
}

if ( event.type === 'tool.progress' ) {
ui.setLoaderMessage( event.message );
continue;
}

if ( event.type === 'agent.question' ) {
ui.showAgentQuestion( event.question, event.options );
continue;
}

if ( event.type === 'turn.closed' ) {
if ( isTurnOpen ) {
ui.endAgentTurn();
isTurnOpen = false;
}
}
}
} finally {
if ( isTurnOpen ) {
ui.endAgentTurn();
}
ui.finishReplay();
}
}
Loading