feat(coding-agent): add session_directory extension event

Adds a session_directory extension event that fires before session manager
creation, allowing extensions to customize the session directory path based
on cwd, git branch, or other context.

- Extensions can return a custom sessionDir in the event handler
- CLI --session-dir flag takes precedence over extension-provided paths
- If multiple extensions return a sessionDir, the last one wins
- Enables implementing branch-based sessions as an extension instead of core feature

This provides the extension point needed to implement git branch-based
session directories without adding the complexity to core.

Closes #1729
This commit is contained in:
Helmut Januschka
2026-03-03 01:08:51 +01:00
committed by Mario Zechner
parent ade6a35e75
commit 7df89066d9
5 changed files with 129 additions and 11 deletions

View File

@@ -4,6 +4,7 @@
### Added
- Added `/tree` branch folding and segment-jump navigation with `Ctrl+←`/`Ctrl+→` and `Alt+←`/`Alt+→`, while keeping `←`/`→` and `Page Up`/`Page Down` for paging ([#1724](https://github.com/badlogic/pi-mono/pull/1724) by [@Perlence](https://github.com/Perlence))
- Added `session_directory` extension event that fires before session manager creation, allowing extensions to customize the session directory path based on cwd and other factors. CLI `--session-dir` flag takes precedence over extension-provided paths ([#1730](https://github.com/badlogic/pi-mono/pull/1730) by [@hjanuschka](https://github.com/hjanuschka)).
### Fixed
- Fixed custom tool collapsed/expanded rendering in HTML exports. Custom tools that define different collapsed vs expanded displays now render correctly in exported HTML, with expandable sections when both states differ and direct display when only expanded exists ([#1934](https://github.com/badlogic/pi-mono/pull/1934) by [@aliou](https://github.com/aliou))
@@ -143,6 +144,10 @@
- Fixed Bedrock `AWS_PROFILE` region resolution by honoring profile `region` values ([#1800](https://github.com/badlogic/pi-mono/issues/1800)).
- Fixed Gemini 3.1 thinking-level detection for `google` and `google-vertex` providers ([#1785](https://github.com/badlogic/pi-mono/issues/1785)).
- Fixed browser bundling compatibility for `@mariozechner/pi-ai` by removing Node-only side effects from default browser import paths ([#1814](https://github.com/badlogic/pi-mono/issues/1814)).
=======
- Added `session_directory` extension event that fires before session manager creation, allowing extensions to customize the session directory path based on cwd and other factors. CLI `--session-dir` flag takes precedence over extension-provided paths ([#1729](https://github.com/badlogic/pi-mono/issues/1729)).
>>>>>>> ddf3c31b (feat(coding-agent): add session_directory extension event)
## [0.55.4] - 2026-03-02

View File

@@ -114,6 +114,8 @@ export type {
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionCompactEvent,
SessionDirectoryEvent,
SessionDirectoryResult,
SessionEvent,
SessionForkEvent,
SessionShutdownEvent,

View File

@@ -851,6 +851,40 @@ export class ExtensionRunner {
return { skillPaths, promptPaths, themePaths };
}
/** Emit session_directory event. Returns custom session directory from extensions (last one wins). */
async emitSessionDirectory(cwd: string, cliSessionDir: string | undefined): Promise<string | undefined> {
const ctx = this.createContext();
let customSessionDir: string | undefined;
for (const ext of this.extensions) {
const handlers = ext.handlers.get("session_directory");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event = { type: "session_directory" as const, cwd, cliSessionDir };
const handlerResult = await handler(event, ctx);
const result = handlerResult as { sessionDir?: string } | undefined;
if (result?.sessionDir) {
customSessionDir = result.sessionDir;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "session_directory",
error: message,
stack,
});
}
}
}
return customSessionDir;
}
/** Emit input event. Transforms chain, "handled" short-circuits. */
async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise<InputEventResult> {
const ctx = this.createContext();

View File

@@ -388,6 +388,14 @@ export interface ResourcesDiscoverResult {
// Session Events
// ============================================================================
/** Fired before session manager creation to allow custom session directory resolution */
export interface SessionDirectoryEvent {
type: "session_directory";
cwd: string;
/** CLI-provided session directory (if any) */
cliSessionDir: string | undefined;
}
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
@@ -472,6 +480,7 @@ export interface SessionTreeEvent {
}
export type SessionEvent =
| SessionDirectoryEvent
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
@@ -866,6 +875,11 @@ export interface BeforeAgentStartEventResult {
systemPrompt?: string;
}
export interface SessionDirectoryResult {
/** Custom session directory path. If multiple extensions return this, the last one wins. */
sessionDir?: string;
}
export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
@@ -936,6 +950,7 @@ export interface ExtensionAPI {
// =========================================================================
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_directory", handler: ExtensionHandler<SessionDirectoryEvent, SessionDirectoryResult>): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(
event: "session_before_switch",

View File

@@ -379,17 +379,75 @@ async function promptConfirm(message: string): Promise<boolean> {
});
}
async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
/** Helper to call session_directory handlers from extensions before runner is fully initialized */
async function callSessionDirectoryHook(
extensions: LoadExtensionsResult,
cwd: string,
cliSessionDir: string | undefined,
): Promise<string | undefined> {
let customSessionDir: string | undefined;
// Minimal context for this early event - most context actions will throw if called
const ctx = {
ui: { notify: () => {}, setStatus: () => {}, setWorkingMessage: () => {} } as any,
hasUI: false,
cwd,
sessionManager: undefined as any,
modelRegistry: undefined as any,
model: undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => process.exit(0),
getContextUsage: () => undefined,
compact: () => {},
getSystemPrompt: () => "",
};
for (const ext of extensions.extensions) {
const handlers = ext.handlers.get("session_directory");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event = { type: "session_directory" as const, cwd, cliSessionDir };
const result = (await handler(event, ctx)) as { sessionDir?: string } | undefined;
if (result?.sessionDir) {
customSessionDir = result.sessionDir;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(chalk.red(`Extension "${ext.path}" session_directory handler failed: ${message}`));
}
}
}
return customSessionDir;
}
async function createSessionManager(
parsed: Args,
cwd: string,
extensions: LoadExtensionsResult,
): Promise<SessionManager | undefined> {
if (parsed.noSession) {
return SessionManager.inMemory();
}
// CLI flag takes precedence, otherwise ask extensions for custom session directory
let effectiveSessionDir = parsed.sessionDir;
if (!effectiveSessionDir) {
effectiveSessionDir = await callSessionDirectoryHook(extensions, cwd, parsed.sessionDir);
}
if (parsed.session) {
const resolved = await resolveSessionPath(parsed.session, cwd, parsed.sessionDir);
const resolved = await resolveSessionPath(parsed.session, cwd, effectiveSessionDir);
switch (resolved.type) {
case "path":
case "local":
return SessionManager.open(resolved.path, parsed.sessionDir);
return SessionManager.open(resolved.path, effectiveSessionDir);
case "global": {
// Session found in different project - ask user if they want to fork
@@ -399,7 +457,7 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
console.log(chalk.dim("Aborted."));
process.exit(0);
}
return SessionManager.forkFrom(resolved.path, cwd, parsed.sessionDir);
return SessionManager.forkFrom(resolved.path, cwd, effectiveSessionDir);
}
case "not_found":
@@ -408,12 +466,12 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
}
}
if (parsed.continue) {
return SessionManager.continueRecent(cwd, parsed.sessionDir);
return SessionManager.continueRecent(cwd, effectiveSessionDir);
}
// --resume is handled separately (needs picker UI)
// If --session-dir provided without --continue/--resume, create new session there
if (parsed.sessionDir) {
return SessionManager.create(cwd, parsed.sessionDir);
// If effective session dir is set, create new session there
if (effectiveSessionDir) {
return SessionManager.create(cwd, effectiveSessionDir);
}
// Default case (new session) returns undefined, SDK will create one
return undefined;
@@ -676,15 +734,19 @@ export async function main(args: string[]) {
}
// Create session manager based on CLI flags
let sessionManager = await createSessionManager(parsed, cwd);
let sessionManager = await createSessionManager(parsed, cwd, extensionsResult);
// Handle --resume: show session picker
if (parsed.resume) {
// Initialize keybindings so session picker respects user config
KeybindingsManager.create();
// Compute effective session dir for resume (same logic as createSessionManager)
const effectiveSessionDir =
parsed.sessionDir || (await callSessionDirectoryHook(extensionsResult, cwd, parsed.sessionDir));
const selectedPath = await selectSession(
(onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress),
(onProgress) => SessionManager.list(cwd, effectiveSessionDir, onProgress),
SessionManager.listAll,
);
if (!selectedPath) {
@@ -692,7 +754,7 @@ export async function main(args: string[]) {
stopThemeWatcher();
process.exit(0);
}
sessionManager = SessionManager.open(selectedPath);
sessionManager = SessionManager.open(selectedPath, effectiveSessionDir);
}
const { options: sessionOptions, cliThinkingFromModel } = buildSessionOptions(