Merge PR #8423: fix(extensions): populate runtime mode in context (@roboomp)

This commit is contained in:
can1357
2026-08-13 16:57:11 +02:00
10 changed files with 69 additions and 3 deletions
+1
View File
@@ -5,6 +5,7 @@
### Fixed
- Fixed the status-line git branch display freezing on the previous branch after the first branch switch, caused by the HEAD watcher binding to a file inode that git unlinks on its atomic HEAD rename ([#8412](https://github.com/can1357/oh-my-pi/issues/8412)).
- Fixed Pi extension contexts omitting the runtime `mode`, which made documented TUI guards silently disable extension UI ([#8419](https://github.com/can1357/oh-my-pi/issues/8419)).
## [17.3.0] - 2026-08-13
@@ -42,6 +42,7 @@ import type {
ExtensionError,
ExtensionEvent,
ExtensionFlag,
ExtensionMode,
ExtensionRuntime,
ExtensionShortcut,
ExtensionUIContext,
@@ -342,6 +343,7 @@ interface ToolRegistrationScope {
export class ExtensionRunner {
#uiContext: ExtensionUIContext;
#mode: ExtensionMode = "print";
#toolApprovalPreviewWaiter?: (toolCallId: string) => Promise<void>;
#errorListeners: Set<ExtensionErrorListener> = new Set();
#getModel: () => Model | undefined = () => undefined;
@@ -525,6 +527,7 @@ export class ExtensionRunner {
contextActions: ExtensionContextActions,
commandContextActions?: ExtensionCommandContextActions,
uiContext?: ExtensionUIContext,
mode: ExtensionMode = "print",
): void {
// Copy actions into the shared runtime (all extension APIs reference this)
this.runtime.sendMessage = actions.sendMessage;
@@ -573,6 +576,7 @@ export class ExtensionRunner {
}
this.#uiContext = uiContext ?? noOpUIContext;
this.#mode = mode;
this.#initialized = true;
// Drain events buffered by emitCredentialDisabled() before initialize ran. The
@@ -953,6 +957,7 @@ export class ExtensionRunner {
const getModel = model ? () => model : this.#getModel;
return {
ui: this.#uiContext,
mode: this.#mode,
getContextUsage: () => this.#getContextUsageFn(),
compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions),
getAsyncJobSnapshot: () => this.#getAsyncJobSnapshotFn(),
@@ -434,9 +434,14 @@ export interface ExtensionModelQuery {
family(model: Model): string;
}
/** Runtime host mode exposed to Pi-compatible extensions. */
export type ExtensionMode = "tui" | "rpc" | "json" | "print";
export interface ExtensionContext {
/** UI methods for user interaction */
ui: ExtensionUIContext;
/** Current run mode. Use `"tui"` to guard terminal-only UI such as custom components. */
mode: ExtensionMode;
/** Get current context usage for the active model. */
getContextUsage(): ContextUsage | undefined;
/** Get a read-only snapshot of async jobs owned by this session. */
@@ -2419,6 +2419,7 @@ export class AcpAgent implements Agent {
compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
},
uiContext,
"rpc",
);
await extensionRunner.emit({ type: "session_start" });
record.extensionsConfigured = true;
@@ -283,7 +283,7 @@ export class ExtensionUiController {
},
};
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
// Subscribe to extension errors
extensionRunner.onError((error: ExtensionError) => {
@@ -512,7 +512,7 @@ export class ExtensionUiController {
},
};
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
}
/**
@@ -118,6 +118,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
}
// Set up extensions for print mode (no UI, no command context)
await initializeExtensions(session, {
mode: mode === "json" ? "json" : "print",
reportSendError: (action, err) => {
process.stderr.write(
`Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`,
@@ -933,6 +933,7 @@ export async function runRpcMode(
// Set up extensions with RPC-based UI context
await initializeExtensions(session, {
mode: "rpc",
reportSendError: (action, err) => {
output(error(undefined, action, err.message));
},
@@ -8,7 +8,7 @@
*/
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
import type { ExtensionError, ExtensionMode, ExtensionUIContext } from "../extensibility/extensions/types";
import type { AgentSession } from "../session/agent-session";
import { USER_INTERRUPT_LABEL } from "../session/messages";
@@ -22,6 +22,8 @@ export interface InitializeExtensionsOptions {
reportRuntimeError: (error: ExtensionError) => void;
/** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */
onShutdown?: () => void;
/** Pi-compatible mode exposed to extension contexts. Defaults to `"print"`. */
mode?: ExtensionMode;
/** Optional UI context (rpc supplies one; print runs headless). */
uiContext?: ExtensionUIContext;
/** Optional lifecycle hook for extension-originated messages that can start an agent turn. */
@@ -44,6 +46,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
reportSendError,
reportRuntimeError,
onShutdown,
mode = "print",
uiContext,
markAgentInvokingMessage,
trackAgentInvokingMessage,
@@ -137,6 +140,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
uiContext,
mode,
);
runner.onError(reportRuntimeError);
@@ -5647,6 +5647,7 @@ export class AgentSession {
return {
ui: noOpUIContext,
mode: "print",
hasUI: false,
cwd: this.sessionManager.getCwd(),
sessionManager: this.sessionManager,
@@ -104,6 +104,53 @@ describe("ExtensionRunner", () => {
expect(runner.createContext().cwd).toBe(dirB);
});
it("exposes the initialized host mode to extension contexts", async () => {
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const actions = {
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
};
const contextActions = {
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
};
expect(runner.createContext().mode).toBe("print");
runner.initialize(actions, contextActions, undefined, undefined, "rpc");
expect(runner.createContext().mode).toBe("rpc");
runner.initialize(actions, contextActions, undefined, undefined, "json");
expect(runner.createContext().mode).toBe("json");
runner.initialize(actions, contextActions, undefined, undefined, "tui");
expect(runner.createContext().mode).toBe("tui");
});
describe("shortcut conflicts", () => {
it("warns when extension shortcut conflicts with built-in", async () => {
const extCode = `