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:
committed by
Mario Zechner
parent
ade6a35e75
commit
7df89066d9
@@ -114,6 +114,8 @@ export type {
|
||||
SessionBeforeTreeEvent,
|
||||
SessionBeforeTreeResult,
|
||||
SessionCompactEvent,
|
||||
SessionDirectoryEvent,
|
||||
SessionDirectoryResult,
|
||||
SessionEvent,
|
||||
SessionForkEvent,
|
||||
SessionShutdownEvent,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user