Reviewed PR and event handling patterns.
UnderstandExplained reentrancy guard options.
UnderstandLocated setSessionName implementation.
UnderstandAdded detailed comment on reentrancy approaches.
EditYou are given one or more GitHub PR URLs: https://github.com/badlogic/pi-mono/pull/964/changes
For each PR URL, do the following in order:
packages/*/CHANGELOG.md files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
### Breaking Changes, ### Added, ### Fixed, etc.)Fixed foo ([#123](https://github.com/badlogic/pi-mono/pull/123) by [@user](https://github.com/user))### Breaking Changes, not just ### FixedOutput format per PR: PR: Changelog:
If no issues are found, say so under Bad and Ugly.
The user wants me to review a GitHub PR. Let me start by reading the PR details.
>gh pr view 964 --repo badlogic/pi-mono --json title,body,comments,commits,files,author,state,labels,headRefName,baseRefName{"author":{"id":"MDQ6VXNlcjczOTY=","is_bot":false,"login":"mitsuhiko","name":"Armin Ronacher"},"baseRefName":"main","body":"Might or might be useful. Useful to me :)","comments":[{"id":"IC_kwDOPbFNk87imXOw","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"@mitsuhiko Do you really need previous and next? Imo extensions should only receive the latest thing in the chain of extensions messing with session metadata.","createdAt":"2026-01-26T20:54:09Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/964#issuecomment-3801707440","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87imdKR","author":{"login":"mitsuhiko"},"authorAssociation":"CONTRIBUTOR","body":"Nope. Will simplify.","createdAt":"2026-01-26T21:00:08Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/964#issuecomment-3801731729","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-01-26T17:06:43Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjczOTY=","login":"mitsuhiko","name":"Armin Ronacher"}],"committedDate":"2026-01-26T17:06:43Z","messageBody":"","messageHeadline":"Emit an event for when session metadata changes","oid":"f3aeebc414e6bf44187874f2e62728e5bbf34a7c"},{"authoredDate":"2026-01-26T17:08:19Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjczOTY=","login":"mitsuhiko","name":"Armin Ronacher"}],"committedDate":"2026-01-26T17:08:19Z","messageBody":"","messageHeadline":"Docs updates","oid":"5637c58c05e07d332d1ff0e1fead92332937a5e3"},{"authoredDate":"2026-01-26T21:18:04Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjczOTY=","login":"mitsuhiko","name":"Armin Ronacher"}],"committedDate":"2026-01-26T21:18:04Z","messageBody":"","messageHeadline":"slop[","oid":"b4658553f44edcb6a14ed4547fb2af2795791df0"}],"files":[{"path":"packages/coding-agent/docs/extensions.md","additions":11,"deletions":0},{"path":"packages/coding-agent/src/core/agent-session.ts","additions":19,"deletions":1},{"path":"packages/coding-agent/src/core/extensions/types.ts","additions":13,"deletions":0},{"path":"packages/coding-agent/src/modes/interactive/interactive-mode.ts","additions":1,"deletions":1}],"headRefName":"session-metadata-change-event","labels":[],"state":"OPEN","title":"Session metadata change event"}>gh pr diff 964 --repo badlogic/pi-monodiff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md
index 53de01172..d1a87bcd6 100644
--- a/packages/coding-agent/docs/extensions.md
+++ b/packages/coding-agent/docs/extensions.md
@@ -310,6 +310,17 @@ pi.on("session_switch", async (event, ctx) => {
});
```
+#### session_metadata_change
+
+Fired when session metadata changes (e.g., session name via `/name` or `pi.setSessionName()`).
+
+```typescript
+pi.on("session_metadata_change", async (event, ctx) => {
+ // event.update contains the latest metadata values
+ ctx.ui.notify(`Session renamed to ${event.update.name}`, "info");
+});
+```
+
#### session_before_fork / session_fork
Fired when forking via `/fork`.
diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index 600d7b91f..7b497a889 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -1695,7 +1695,7 @@ export class AgentSession {
this.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
- this.sessionManager.appendSessionInfo(name);
+ this.setSessionName(name);
},
getSessionName: () => {
return this.sessionManager.getSessionName();
@@ -2095,6 +2095,24 @@ export class AgentSession {
// Session Management
// =========================================================================
+ /**
+ * Set the session display name and emit a metadata change event.
+ */
+ setSessionName(name: string): void {
+ const trimmed = name.trim();
+ const previousName = this.sessionManager.getSessionName();
+ this.sessionManager.appendSessionInfo(trimmed);
+
+ if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) {
+ void this._extensionRunner.emit({
+ type: "session_metadata_change",
+ update: {
+ name: trimmed,
+ },
+ });
+ }
+ }
+
/**
* Switch to a different session file.
* Aborts current operation, loads messages, restores model/thinking.
diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts
index 47e6051af..5f6f557e9 100644
--- a/packages/coding-agent/src/core/extensions/types.ts
+++ b/packages/coding-agent/src/core/extensions/types.ts
@@ -350,6 +350,17 @@ export interface SessionSwitchEvent {
previousSessionFile: string | undefined;
}
+/** Latest session metadata values. */
+export interface SessionMetadataUpdate {
+ name: string;
+}
+
+/** Fired when session metadata changes (e.g., name). */
+export interface SessionMetadataChangeEvent {
+ type: "session_metadata_change";
+ update: SessionMetadataUpdate;
+}
+
/** Fired before forking a session (can be cancelled) */
export interface SessionBeforeForkEvent {
type: "session_before_fork";
@@ -418,6 +429,7 @@ export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
+ | SessionMetadataChangeEvent
| SessionBeforeForkEvent
| SessionForkEvent
| SessionBeforeCompactEvent
@@ -740,6 +752,7 @@ export interface ExtensionAPI {
handler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,
): void;
on(event: "session_switch", handler: ExtensionHandler<SessionSwitchEvent>): void;
+ on(event: "session_metadata_change", handler: ExtensionHandler<SessionMetadataChangeEvent>): void;
on(event: "session_before_fork", handler: ExtensionHandler<SessionBeforeForkEvent, SessionBeforeForkResult>): void;
on(event: "session_fork", handler: ExtensionHandler<SessionForkEvent>): void;
on(
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index 72bd88bd5..d4f08e025 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -3824,7 +3824,7 @@ export class InteractiveMode {
return;
}
- this.sessionManager.appendSessionInfo(name);
+ this.session.setSessionName(name);
this.updateTerminalTitle();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0));Let me now read the relevant source files to understand the context better. I need to read:
continue
Let me now read the relevant source files to understand the context better. I need to read:
Let me continue reading the agent-session.ts to see the full context around the setSessionName method and where it's being used. I also need to check the interactive-mode.ts file to understand the change there.
Now I have a good understanding of the PR. Let me check if there are any related issues referenced and also verify the current state of the code around the handleNameCommand function. It appears there's a discrepancy - the diff shows changing from this.sessionManager.appendSessionInfo(name) to this.session.setSessionName(name), but the file I just read still shows this.sessionManager.appendSessionInfo(name) at line 3824 (the interactive-mode.ts file offset). Let me check if the PR is targeting a different version of the file.
Actually wait, I need to re-read the file to see the current state on main. The diff shows:
this.sessionManager.appendSessionInfo(name);this.session.setSessionName(name);But the current file at line 3824 shows this.sessionManager.appendSessionInfo(name) which is the "before" state, so that matches. The PR changes this to this.session.setSessionName(name).
Now let me verify what happens when the event is not fired (when there are no handlers). Let me check the code again around line 2095-2118 in agent-session.ts to see the new setSessionName method added by this PR.
Looking at the diff:
setSessionName(name: string): void { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed, }, }); }}This looks good! It:
Trims the name
Gets the previous name
Appends the session info (as before)
Only emits the event if the name actually changed AND there are handlers registered
The extension API's setSessionName call now routes through this.setSessionName(name) instead of directly calling this.sessionManager.appendSessionInfo(name). The documentation correctly shows how to use the session_metadata_change event. I should verify if there are any linked issues in the PR and check whether a changelog entry is needed.
The PR title is "Session metadata change event" with a description indicating @mitsuhiko agreed to simplify by removing previous/next comparisons. No linked issues exist, but I notice the CHANGELOG.md lacks an entry for this new event in the [Unreleased] section. The commit message appears to have a typo ("slop["). Looking at the implementation more closely, the new setSessionName method in agent-session.ts properly emits the event, the ExtensionAPI routes through the AgentSession method, and interactive mode's handleNameCommand uses the session method instead of directly accessing sessionManager. New types have been added to support this functionality. The documentation example correctly shows how to access the updated metadata through event.update.name. Checking the type consistency: SessionMetadataUpdate defines name: string, SessionMetadataChangeEvent wraps it with the event type, and the emit call constructs the object with matching structure. The fire-and-forget pattern using void is consistent with other events in the codebase, though it means extension errors won't propagate back to the caller. set the same name twice, the event won't fire. If the name is cleared with an empty string, it would be saved as "" since the trim happens before storage. Looking at how handleNameCommand works in interactive mode, it requires a non-empty name and shows usage otherwise, so empty names shouldn't come through the UI. However, the extension API could theoretically call setSessionName(""), which might be worth considering as an edge case.
The PR itself appears solid overall. I should flag the missing changelog entry and note the typo in the commit message ("slop[") before finalizing my review.
Let me check if there are any issues linked in the PR and verify the current state of the handleNameCommand:
>cd /Users/badlogic/workspaces/pi-mono && git log --oneline -5 mainf86e3c31 fix(coding-agent): sync agent state after newSession setup callback
0134a2a7 fix(coding-agent): handle force-pushed git extensions in pi update
bfb21e31 fix(coding-agent): handle force-pushed git extensions in pi update
3c252d50 docs(coding-agent): add placement note for extension hot-reload
3ae0cbe3 fix(coding-agent): Title Case extension hotkeys in /hotkeys output (#969)Good, I can see the current state of main. The PR is based on this state. Now let me provide my review.
Now let me verify the extension runner emit pattern used elsewhere for comparison:
>cd /Users/badlogic/workspaces/pi-mono && rg "void this._extensionRunner" packages/coding-agent/src --no-heading | head -20(no output)No matches with void this._extensionRunner. Let me check how other events are emitted.
>cd /Users/badlogic/workspaces/pi-mono && rg "_extensionRunner.emit" packages/coding-agent/src/core/agent-session.ts --no-heading | head -15await this._extensionRunner.emit({ type: "agent_start" });
await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
await this._extensionRunner.emit(extensionEvent);
await this._extensionRunner.emit(extensionEvent);
const inputResult = await this._extensionRunner.emitInput(
const result = await this._extensionRunner.emitBeforeAgentStart(
this._extensionRunner.emitError({
const result = (await this._extensionRunner.emit({
await this._extensionRunner.emit({
await this._extensionRunner.emit({
const result = (await this._extensionRunner.emit({
await this._extensionRunner.emit({
const extensionResult = (await this._extensionRunner.emit({
await this._extensionRunner.emit({
await this._extensionRunner.emit({ type: "session_start" });I see that most of the event emissions use await, not void. The PR uses void this._extensionRunner.emit(...) which is a fire-and-forget pattern. This is inconsistent with the rest of the codebase. The event should probably be awaited for consistency.
However, looking at the context, this event is emitted from a sync method setSessionName(name: string): void. If we await the emit, we'd need to make it async. Let me check how other sync methods handle this.
Actually, looking more carefully at the code flow:
setSessionName is called from interactive mode's handleNameCommandsetSessionNameBoth of these contexts can handle async. Let me verify this is indeed an inconsistency or if there's a good reason for the fire-and-forget pattern.
Looking at the pattern, I think the void is intentional here because:
But it IS inconsistent with other events in the same file that await. This could be a legitimate concern.
Let me finalize my review.
PR: https://github.com/badlogic/pi-mono/pull/964
Changelog:
Good:
session_metadata_change event that fires when session name changes via /name or pi.setSessionName()hasHandlers() check to avoid unnecessary workpreviousName !== trimmed)extensions.md is clear and correctly placedSessionMetadataUpdate and SessionMetadataChangeEventAgentSession.setSessionName() so both interactive mode and extension API go through the same pathBad:
void this._extensionRunner.emit(...) (fire-and-forget) while all other events in agent-session.ts use await this._extensionRunner.emit(...). This could cause issues if extension handlers throw errors or if ordering matters.pkg:* labels on the PR.Ugly:
Questions or Assumptions:
void) pattern intentional? Making setSessionName async would require updating the method signature and callers, but would be more consistent with the rest of the codebase.update.name) matches what was agreed.Change summary:
SessionMetadataUpdate and SessionMetadataChangeEvent types to extensions/types.tssession_metadata_change to SessionEvent union and ExtensionAPI.on() overloadssetSessionName(name: string) method to AgentSession that:
sessionManager.appendSessionInfo()session_metadata_change event if name changed and handlers existInteractiveMode.handleNameCommand() to use session.setSessionName() instead of sessionManager.appendSessionInfo()setSessionName callback to use this.setSessionName(name) instead of this.sessionManager.appendSessionInfo(name)extensions.mdTests:
/name or pi.setSessionName().is this reentrant? like what would be purpose of session_metadata_change be other than maybe fixing up the new name and setting a new one?
The user is asking about reentrancy - what happens if an extension handler for session_metadata_change calls pi.setSessionName() again? Would it cause infinite recursion?
Let me trace through the code:
User calls /name foo
InteractiveMode.handleNameCommand() calls this.session.setSessionName("foo")
AgentSession.setSessionName("foo"):
this.sessionManager.appendSessionInfo("foo") - name is now "foo"previousName !== trimmed → truehasHandlers("session_metadata_change") → true (if registered)void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: "foo" } })Extension handler fires:
pi.on("session_metadata_change", async (event, ctx) => { // What if we call pi.setSessionName("bar") here?});If the extension calls pi.setSessionName("bar"):
this.setSessionName("bar")AgentSession.setSessionName("bar"):
this.sessionManager.appendSessionInfo("bar") - name is now "bar"previousName !== trimmed → true ("foo" !== "bar")This could cause infinite recursion if the handler always sets a different name!
The user is right to ask about this. The pattern isn't reentrant-safe. If the purpose is to allow extensions to "fix up" the name (e.g., sanitize it, add a prefix), then calling setSessionName again would trigger another event, which would trigger another call, etc.
Several approaches could address this: adding a reentrancy guard flag, suppressing events during handler execution, or making this a pre-change event that returns a modified value rather than a post-hoc notification. The current design treats session_metadata_change as a notification that fires after the change is already applied, unlike earlier-stage events like session_before_switch that can influence behavior. If extensions need to transform the name, a session_before_metadata_change event that allows returning a modified value would be more appropriate than having handlers call setSessionName themselves.
Good catch. Looking at the code flow:
setSessionName("foo") is calledappendSessionInfo)update: { name: "foo" }pi.setSessionName("bar")update: { name: "bar" }It's not reentrant-safe. The event fires AFTER the name is already persisted, so if an extension wants to "fix up" the name, it would call setSessionName() again, triggering another event.
If the purpose is just notification (sync to external system, update UI, log), then it's fine but should be documented that handlers must not call setSessionName().
If the purpose is transformation (sanitize, add prefix, validate), then this needs to be a session_before_metadata_change event that returns the modified value:
pi.on("session_before_metadata_change", async (event, ctx) => { // event.name is the proposed name return { name: event.name.toUpperCase() }; // transform it // or: return { cancel: true }; // reject it});Given @mitsuhiko's comment "Useful to me :)" without elaboration, unclear which use case this targets. Worth asking what the actual use case is before merging.
ok, what would be the good solution. are we already doing reentrant safety in other event handlers?
Let me check if there are any reentrancy guards in the extension system or if other events have similar issues.
>cd /Users/badlogic/workspaces/pi-mono && rg -n "reentrant|_emitting|_isEmitting|_inHandler" packages/coding-agent/src --type ts(no output)
Command exited with code 1Let me find the extension runner file.
>cd /Users/badlogic/workspaces/pi-mono && find packages/coding-agent/src -name "*runner*" -o -name "*extension*" | head -20packages/coding-agent/src/core/extensions
packages/coding-agent/src/core/extensions/runner.ts
packages/coding-agent/src/modes/interactive/components/extension-selector.ts
packages/coding-agent/src/modes/interactive/components/extension-editor.ts
packages/coding-agent/src/modes/interactive/components/extension-input.tsFound it at packages/coding-agent/src/core/extensions/runner.ts. Let me read that to understand how events are emitted.
Looking at the extension runner, I don't see any built-in reentrancy protection. The emit method just iterates through handlers and calls them.
Let me think about the good solution options:
Add reentrancy guard in setSessionName: Track if we're currently emitting this event and skip re-emission. Simple but specific to this one event.
Make it a "before" event with transform capability: Change to session_before_metadata_change that can return a modified name. This is the cleanest API but more work.
Document that handlers must not call setSessionName: Weakest solution, relies on users reading docs.
Add a general reentrancy guard in ExtensionRunner: More complex, would need to track which events are currently being processed per-type.
Looking at the existing events:
session_before_switch, session_before_fork, session_before_compact, session_before_tree - these are "before" events that can cancel or modify behaviorsession_switch, session_fork, session_compact, session_tree - these are "after" events for notificationThe pattern in the codebase is:
So session_metadata_change follows the "after" pattern correctly. But if the use case is to transform the name, it should be a "before" event.
The simplest fix that's consistent with the codebase patterns would be:
session_metadata_change as a notification-only event (don't call setSessionName from handler)setSessionNameLet me check if any other "after" events have similar reentrancy concerns. For example, what if a session_switch handler calls ctx.newSession()? That would also be problematic.
Actually, looking at the ExtensionContext for event handlers, they don't have newSession, fork, etc.
Event handlers receive a limited ExtensionContext that doesn't expose setSessionName, while the pi object in extensions does have it. So from within a session_metadata_change handler, I'd need to call pi.setSessionName to trigger reentrancy—it's not directly available through the event context itself.
The pi object gets captured in closure and remains accessible to all handlers, making reentrancy possible. A straightforward solution would be adding a guard flag to setSessionName that returns early if already in progress, preventing recursive calls from propagating further.
However, using void for fire-and-forget emission means the finally block executes before async handlers complete, so the flag gets reset prematurely. Converting to an async approach where the method awaits the emission would properly maintain the flag throughout the entire operation, ensuring handlers can't trigger reentrancy even if they're asynchronous.
The trade-off is that callers would need to await the method, though the existing call sites like handleNameCommand and the extension API's setSessionName could accommodate this change.
However, the extension API defines setSessionName as returning void, and changing it to async would alter the type signature—a breaking change. Instead, I could keep the method synchronous and use fire-and-forget emission, or document that calling setSessionName from within a session_metadata_change handler produces undefined behavior.
The most practical approach is adding a reentrancy guard while maintaining the sync interface, though this only prevents immediate reentry rather than delayed async callbacks.
Looking at the runner, there's no built-in reentrancy protection. The pi object is captured in closure, so handlers can call pi.setSessionName() even though ctx doesn't expose it.
Existing patterns:
session_before_*) can cancel/modifysession_switch, etc.) are notification-onlyNo other events have reentrancy guards. But most "after" events don't expose mutation methods that would loop back.
Good solutions, in order of preference:
Option 1: Simple reentrancy guard (minimal change)
private _isSettingSessionName = false;
setSessionName(name: string): void { if (this._isSettingSessionName) return; // Silently ignore reentrant calls const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { this._isSettingSessionName = true; void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed }, }).finally(() => { this._isSettingSessionName = false; }); }}Option 2: "Before" event for transformation (cleaner API, more work)
// Allow extensions to transform/cancelconst result = await this._extensionRunner.emit({ type: "session_before_metadata_change", proposedName: trimmed,});if (result?.cancel) return;const finalName = result?.name ?? trimmed;this.sessionManager.appendSessionInfo(finalName);// Then emit session_metadata_change as notificationRecommendation: Option 1 is sufficient for a notification-only event. If @mitsuhiko's use case requires transformation, Option 2 is the right pattern. Worth asking what the actual use case is.
how is reentrancy achieved in option 2?
session_before_metadata_change triggers a handler that does pi.setSessionName
The user is right - Option 2 doesn't solve reentrancy either. If a handler for session_before_metadata_change calls pi.setSessionName(), we'd have the same problem.
The only difference is:
session_metadata_change handler calls pi.setSessionName() → loopssession_before_metadata_change handler calls pi.setSessionName() → still loopsThe reentrancy guard is needed in BOTH cases. Option 2 just provides a cleaner API for transformation (return the modified name instead of calling setSessionName), but it doesn't inherently prevent reentrancy.
The REAL solution is:
_isSettingSessionName flag)So Option 2 should be:
private _isSettingSessionName = false;
async setSessionName(name: string): Promise<void> { if (this._isSettingSessionName) return; this._isSettingSessionName = true; try { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); let finalName = trimmed; if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_before_metadata_change")) { const result = await this._extensionRunner.emit({ type: "session_before_metadata_change", proposedName: trimmed, }); if (result?.cancel) return; finalName = result?.name ?? trimmed; } this.sessionManager.appendSessionInfo(finalName); // Notification event (still need guard for this too) if (this._extensionRunner?.hasHandlers("session_metadata_change")) { await this._extensionRunner.emit({ type: "session_metadata_change", update: { name: finalName }, }); } } finally { this._isSettingSessionName = false; }}The key insight is: the reentrancy guard is ALWAYS needed. The "before" event just provides a better API pattern for transformation (return value vs. calling mutation method). If handlers use the return value from the "before" event instead of calling setSessionName() directly, they avoid reentrancy altogether—but the guard still protects against accidental misuse.
You're right, it doesn't. A misbehaving handler could still call pi.setSessionName() from within session_before_metadata_change.
The reentrancy guard is needed regardless of the event pattern. Option 2's benefit is just that it provides a "correct" way to transform (via return value), so well-behaved extensions don't need to call setSessionName(). But it doesn't prevent bad behavior.
The actual solution is:
private _isSettingSessionName = false;
setSessionName(name: string): void { if (this._isSettingSessionName) return; this._isSettingSessionName = true; try { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed }, }).finally(() => { this._isSettingSessionName = false; }); return; // Flag cleared in finally } } finally { // Only clear if we didn't start an async emit if (!this._extensionRunner?.hasHandlers("session_metadata_change")) { this._isSettingSessionName = false; } }}Hmm, that's getting ugly with the async flag management. Cleaner:
private _isSettingSessionName = false;
setSessionName(name: string): void { if (this._isSettingSessionName) return; const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { this._isSettingSessionName = true; this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed }, }).finally(() => { this._isSettingSessionName = false; }); }}Guard only active during event emission. Reentrant calls during handler execution are silently ignored.
where is setSessionNAme defined?
The user is asking where setSessionName is defined. Let me check the PR diff again and the current codebase.
From the PR diff, the new setSessionName method is added to AgentSession class in agent-session.ts:
/** * Set the session display name and emit a metadata change event. */setSessionName(name: string): void { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed, }, }); }}This is added around line 2098-2117 in the diff.
There's also the extension API's setSessionName which is a callback defined in the AgentSession class when building the extension context. Looking at the diff:
setSessionName: (name) => { this.setSessionName(name);},This was changed from:
setSessionName: (name) => { this.sessionManager.appendSessionInfo(name);},So now the extension API's setSessionName calls the AgentSession.setSessionName method instead of directly calling sessionManager.
The user might be asking to understand where the reentrancy guard should be added - it should be in the AgentSession.setSessionName method since that's the single point of entry now (both interactive mode and extension API go through it).
From the PR diff, it's being added to AgentSession class in agent-session.ts (around line 2098):
/** * Set the session display name and emit a metadata change event. */setSessionName(name: string): void { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed, }, }); }}And the extension API callback (around line 1698) is changed to call it:
// Before:setSessionName: (name) => { this.sessionManager.appendSessionInfo(name);},
// After:setSessionName: (name) => { this.setSessionName(name);},So the reentrancy guard goes in the new AgentSession.setSessionName() method - it's now the single entry point for both interactive mode (/name) and extension API (pi.setSessionName()).
this all sucks
The user is frustrated with the complexity of this solution. Let me think about simpler alternatives.
The core issue is: we're adding an event that fires when session metadata changes, but if a handler tries to change the metadata again, we get infinite recursion.
Options:
Actually, stepping back... what's the actual use case? @mitsuhiko said "Useful to me :)" but didn't explain why.
The simplest approach might be to just ask: what do you actually need this for?
If it's just notification (e.g., update a status bar, sync to external system), then:
If it's transformation (e.g., sanitize names, add prefixes), then:
The user saying "this all sucks" might mean:
I should acknowledge the complexity and suggest the simplest path forward.
Yeah. The simplest options:
Just don't emit events from setSessionName - if extensions want to react to name changes, they can register a /name command override. But that doesn't catch pi.setSessionName() calls from other extensions.
Accept the risk - document "don't call pi.setSessionName() from session_metadata_change handlers" and move on. Most events have similar footguns if you try hard enough.
Ask @mitsuhiko what the actual use case is - if it's just "sync name to external system" or "update UI", the current PR is fine as-is. If it's "transform/validate names", different design needed.
Given the comment thread shows he already simplified based on feedback, probably worth just asking:
What's the use case? If it's notification-only (sync to external system, update UI), the current PR is fine. If you need to transform/validate names, we need a
session_before_metadata_changepattern instead.
The reentrancy guard adds complexity for a problem that might not exist in practice. YAGNI.
ok, add a slop comment. detail the two approaches
The user wants me to add a comment to the PR detailing the two approaches for handling reentrancy. Let me compose a clear, concise comment that explains the options.
>gh pr comment 964 --repo badlogic/pi-mono --body "What's the use case for this event? Notification-only (sync to external system, update UI) or transformation (sanitize/validate names)?
Current implementation isn't reentrant-safe. If a handler calls \`pi.setSessionName()\`, you get infinite recursion.
Two approaches:
**1. Reentrancy guard in \`setSessionName\` (simple)**
Add a flag to prevent recursive emission. If multiple handlers listen to \`session_metadata_change\`, they all receive the same name. If they all call \`pi.setSessionName()\`, last write wins. No recursive emission, no chaining, but nothing explodes.
\`\`\`typescript
private _isSettingSessionName = false;
setSessionName(name: string): void {
if (this._isSettingSessionName) return;
this._isSettingSessionName = true;
// ... emit event ...
// clear flag in .finally()
}
\`\`\`
**2. Option 1 + \`session_before_metadata_change\` (if transformation needed)**
Adds a \"before\" event that chains handler results. Each handler receives the output from the previous handler and can return a modified name. The final result is what gets persisted.
\`\`\`typescript
pi.on(\"session_before_metadata_change\", async (event, ctx) => {
return { name: event.proposedName.toUpperCase() }; // transform
// or: return { cancel: true }; // reject
});
\`\`\`
Caveat: \`getSessionName()\` returns the original name during handler execution since the new name isn't persisted yet. Probably fine.
If your use case is notification-only, option 1 is enough. Let us know."https://github.com/badlogic/pi-mono/pull/964#issuecomment-3801973033Done. I added a comment to the PR asking for the use case and explaining the two approaches for handling reentrancy.
Done. Comment added asking for the use case and explaining both approaches.
setSessionName(name: string): void { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed, }, }); }}pi.on("session_metadata_change", async (event, ctx) => { // What if we call pi.setSessionName("bar") here?});pi.on("session_before_metadata_change", async (event, ctx) => { // event.name is the proposed name return { name: event.name.toUpperCase() }; // transform it // or: return { cancel: true }; // reject it});private _isSettingSessionName = false;
setSessionName(name: string): void { if (this._isSettingSessionName) return; // Silently ignore reentrant calls const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { this._isSettingSessionName = true; void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed }, }).finally(() => { this._isSettingSessionName = false; }); }}// Allow extensions to transform/cancelconst result = await this._extensionRunner.emit({ type: "session_before_metadata_change", proposedName: trimmed,});if (result?.cancel) return;const finalName = result?.name ?? trimmed;this.sessionManager.appendSessionInfo(finalName);// Then emit session_metadata_change as notificationprivate _isSettingSessionName = false;
async setSessionName(name: string): Promise<void> { if (this._isSettingSessionName) return; this._isSettingSessionName = true; try { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); let finalName = trimmed; if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_before_metadata_change")) { const result = await this._extensionRunner.emit({ type: "session_before_metadata_change", proposedName: trimmed, }); if (result?.cancel) return; finalName = result?.name ?? trimmed; } this.sessionManager.appendSessionInfo(finalName); // Notification event (still need guard for this too) if (this._extensionRunner?.hasHandlers("session_metadata_change")) { await this._extensionRunner.emit({ type: "session_metadata_change", update: { name: finalName }, }); } } finally { this._isSettingSessionName = false; }}private _isSettingSessionName = false;
setSessionName(name: string): void { if (this._isSettingSessionName) return; this._isSettingSessionName = true; try { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed }, }).finally(() => { this._isSettingSessionName = false; }); return; // Flag cleared in finally } } finally { // Only clear if we didn't start an async emit if (!this._extensionRunner?.hasHandlers("session_metadata_change")) { this._isSettingSessionName = false; } }}private _isSettingSessionName = false;
setSessionName(name: string): void { if (this._isSettingSessionName) return; const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { this._isSettingSessionName = true; this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed }, }).finally(() => { this._isSettingSessionName = false; }); }}/** * Set the session display name and emit a metadata change event. */setSessionName(name: string): void { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed, }, }); }}setSessionName: (name) => { this.setSessionName(name);},setSessionName: (name) => { this.sessionManager.appendSessionInfo(name);},/** * Set the session display name and emit a metadata change event. */setSessionName(name: string): void { const trimmed = name.trim(); const previousName = this.sessionManager.getSessionName(); this.sessionManager.appendSessionInfo(trimmed);
if (previousName !== trimmed && this._extensionRunner?.hasHandlers("session_metadata_change")) { void this._extensionRunner.emit({ type: "session_metadata_change", update: { name: trimmed, }, }); }}// Before:setSessionName: (name) => { this.sessionManager.appendSessionInfo(name);},
// After:setSessionName: (name) => { this.setSessionName(name);},