Consolidate session events: remove session_before_new/session_new, add reason field to switch events

- Remove session_before_new and session_new hook events
- Add reason: 'new' | 'resume' to session_before_switch and session_switch events
- Remove 'new' reason from custom tool onSession (use 'switch' for both /new and /resume)
- Rename reset() to newSession(options?) in AgentSession
- Add NewSessionOptions with optional parentSession for lineage tracking
- Rename branchedFrom to parentSession in SessionHeader
- Rename RPC reset command to new_session with optional parentSession
- Update example hooks to use new event structure
- Update documentation and changelog

Based on discussion in #293
This commit is contained in:
Mario Zechner 2026-01-01 23:31:26 +01:00
parent 1d9fa13d58
commit 484d7e06bb
19 changed files with 117 additions and 117 deletions

View file

@ -34,7 +34,6 @@ import type {
HookRunner,
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeNewResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
TreePreparation,
@ -43,7 +42,7 @@ import type {
} from "./hooks/index.js";
import type { BashExecutionMessage, HookMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
@ -664,19 +663,21 @@ export class AgentSession {
}
/**
* Reset agent and session to start fresh.
* Start a new session, optionally with initial messages and parent tracking.
* Clears all messages and starts a new session.
* Listeners are preserved and will continue receiving events.
* @returns true if reset completed, false if cancelled by hook
* @param options - Optional initial messages and parent session path
* @returns true if completed, false if cancelled by hook
*/
async reset(): Promise<boolean> {
async newSession(options?: NewSessionOptions): Promise<boolean> {
const previousSessionFile = this.sessionFile;
// Emit session_before_new event (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_new")) {
// Emit session_before_switch event with reason "new" (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
type: "session_before_new",
})) as SessionBeforeNewResult | undefined;
type: "session_before_switch",
reason: "new",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
@ -686,19 +687,21 @@ export class AgentSession {
this._disconnectFromAgent();
await this.abort();
this.agent.reset();
this.sessionManager.newSession();
this.sessionManager.newSession(options);
this._queuedMessages = [];
this._reconnectToAgent();
// Emit session_new event to hooks
// Emit session_switch event with reason "new" to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
type: "session_new",
type: "session_switch",
reason: "new",
previousSessionFile,
});
}
// Emit session event to custom tools
await this.emitCustomToolSessionEvent("new", previousSessionFile);
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
return true;
}
@ -1446,6 +1449,7 @@ export class AgentSession {
if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
type: "session_before_switch",
reason: "resume",
targetSessionFile: sessionPath,
})) as SessionBeforeSwitchResult | undefined;
@ -1468,6 +1472,7 @@ export class AgentSession {
if (this._hookRunner) {
await this._hookRunner.emit({
type: "session_switch",
reason: "resume",
previousSessionFile,
});
}

View file

@ -52,8 +52,8 @@ export interface CustomToolContext {
/** Session event passed to onSession callback */
export interface CustomToolSessionEvent {
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
/** Previous session file path, or undefined for "start", "new", and "shutdown" */
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
/** Previous session file path, or undefined for "start" and "shutdown" */
previousSessionFile: string | undefined;
}

View file

@ -154,27 +154,21 @@ export interface SessionStartEvent {
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Session file we're switching to */
targetSessionFile: string;
/** Reason for the switch */
reason: "new" | "resume";
/** Session file we're switching to (only for "resume") */
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Reason for the switch */
reason: "new" | "resume";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before creating a new session (can be cancelled) */
export interface SessionBeforeNewEvent {
type: "session_before_new";
}
/** Fired after creating a new session */
export interface SessionNewEvent {
type: "session_new";
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
@ -255,8 +249,6 @@ export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeNewEvent
| SessionNewEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
@ -505,12 +497,6 @@ export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
/** Return type for session_before_new handlers */
export interface SessionBeforeNewResult {
/** If true, cancel the new session */
cancel?: boolean;
}
/** Return type for session_before_branch handlers */
export interface SessionBeforeBranchResult {
/**
@ -600,8 +586,6 @@ export interface HookAPI {
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
on(event: "session_before_switch", handler: HookHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>): void;
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
on(event: "session_before_new", handler: HookHandler<SessionBeforeNewEvent, SessionBeforeNewResult>): void;
on(event: "session_new", handler: HookHandler<SessionNewEvent>): void;
on(event: "session_before_branch", handler: HookHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>): void;
on(event: "session_branch", handler: HookHandler<SessionBranchEvent>): void;
on(

View file

@ -31,7 +31,11 @@ export interface SessionHeader {
id: string;
timestamp: string;
cwd: string;
branchedFrom?: string;
parentSession?: string;
}
export interface NewSessionOptions {
parentSession?: string;
}
export interface SessionEntryBase {
@ -508,7 +512,7 @@ export class SessionManager {
}
}
newSession(): string | undefined {
newSession(options?: NewSessionOptions): string | undefined {
this.sessionId = randomUUID();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
@ -517,11 +521,13 @@ export class SessionManager {
id: this.sessionId,
timestamp,
cwd: this.cwd,
parentSession: options?.parentSession,
};
this.fileEntries = [header];
this.byId.clear();
this.leafId = null;
this.flushed = false;
// Only generate filename if persisting and not already set (e.g., via --session flag)
if (this.persist && !this.sessionFile) {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@ -929,7 +935,7 @@ export class SessionManager {
id: newSessionId,
timestamp,
cwd: this.cwd,
branchedFrom: this.persist ? this.sessionFile : undefined,
parentSession: this.persist ? this.sessionFile : undefined,
};
// Collect labels for entries in the path

View file

@ -107,6 +107,7 @@ export {
getLatestCompactionEntry,
type ModelChangeEntry,
migrateSessionEntries,
type NewSessionOptions,
parseSessionEntries,
type SessionContext,
type SessionEntry,

View file

@ -2148,8 +2148,8 @@ export class InteractiveMode {
}
this.statusContainer.clear();
// Reset via session (emits hook and tool session events)
await this.session.reset();
// New session via session (emits hook and tool session events)
await this.session.newSession();
// Clear UI state
this.chatContainer.clear();

View file

@ -187,11 +187,12 @@ export class RpcClient {
}
/**
* Reset session (clear all messages).
* @returns Object with `cancelled: true` if a hook cancelled the reset
* Start a new session, optionally with parent tracking.
* @param parentSession - Optional parent session path for lineage tracking
* @returns Object with `cancelled: true` if a hook cancelled the new session
*/
async reset(): Promise<{ cancelled: boolean }> {
const response = await this.send({ type: "reset" });
async newSession(parentSession?: string): Promise<{ cancelled: boolean }> {
const response = await this.send({ type: "new_session", parentSession });
return this.getData(response);
}

View file

@ -239,9 +239,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return success(id, "abort");
}
case "reset": {
const cancelled = !(await session.reset());
return success(id, "reset", { cancelled });
case "new_session": {
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
const cancelled = !(await session.newSession(options));
return success(id, "new_session", { cancelled });
}
// =================================================================

View file

@ -20,7 +20,7 @@ export type RpcCommand =
| { id?: string; type: "prompt"; message: string; images?: ImageContent[] }
| { id?: string; type: "queue_message"; message: string }
| { id?: string; type: "abort" }
| { id?: string; type: "reset" }
| { id?: string; type: "new_session"; parentSession?: string }
// State
| { id?: string; type: "get_state" }
@ -87,7 +87,7 @@ export type RpcResponse =
| { id?: string; type: "response"; command: "prompt"; success: true }
| { id?: string; type: "response"; command: "queue_message"; success: true }
| { id?: string; type: "response"; command: "abort"; success: true }
| { id?: string; type: "response"; command: "reset"; success: true; data: { cancelled: boolean } }
| { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } }
// State
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }