From 426fea73346e7e955ef8e13f0cf2871fb8d96f51 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 7 May 2026 10:31:34 +0200 Subject: [PATCH] fix: reload sf source runtime on extension changes --- bin/sf-from-source | 25 ++++-- .../src/core/extensions/runner.test.ts | 89 +++++++++++++++++++ .../src/core/extensions/runner.ts | 8 +- .../src/modes/interactive/interactive-mode.ts | 54 +++++++++++ 4 files changed, 170 insertions(+), 6 deletions(-) diff --git a/bin/sf-from-source b/bin/sf-from-source index 16ecd2087..2e5ef2649 100755 --- a/bin/sf-from-source +++ b/bin/sf-from-source @@ -53,8 +53,23 @@ if [[ "$IS_HEADLESS" == "1" ]]; then echo "[forge] Launching source CLI..." fi -exec "$NODE_BIN" \ - --import "$SF_SOURCE_ROOT/src/resources/extensions/sf/tests/resolve-ts.mjs" \ - --experimental-strip-types \ - --no-warnings \ - "$SF_SOURCE_ROOT/src/loader.ts" "$@" +ORIGINAL_ARGS=("$@") +NEXT_ARGS=("${ORIGINAL_ARGS[@]}") +while true; do + set +e + "$NODE_BIN" \ + --import "$SF_SOURCE_ROOT/src/resources/extensions/sf/tests/resolve-ts.mjs" \ + --experimental-strip-types \ + --no-warnings \ + "$SF_SOURCE_ROOT/src/loader.ts" "${NEXT_ARGS[@]}" + status=$? + set -e + + if [[ "$status" == "12" && "$IS_HEADLESS" != "1" && -t 0 && -t 1 ]]; then + echo "[forge] Runtime reload requested — restarting source CLI with --continue..." + NEXT_ARGS=("--continue") + continue + fi + + exit "$status" +done diff --git a/packages/pi-coding-agent/src/core/extensions/runner.test.ts b/packages/pi-coding-agent/src/core/extensions/runner.test.ts index c76846646..a15bb85f7 100644 --- a/packages/pi-coding-agent/src/core/extensions/runner.test.ts +++ b/packages/pi-coding-agent/src/core/extensions/runner.test.ts @@ -144,3 +144,92 @@ describe("ExtensionRunner UI compatibility", () => { } }); }); + +describe("ExtensionRunner command conflicts", () => { + it("registered_command_when_reserved_for_builtin_delegation_is_hidden_without_warning", () => { + const dir = mkdtempSync(join(tmpdir(), "runner-command-test-")); + try { + const sessionManager = SessionManager.create(dir, dir); + const authStorage = AuthStorage.create(); + const modelRegistry = new ModelRegistry( + authStorage, + join(dir, "models.json"), + ); + const commands = new Map(); + commands.set("exit", { + name: "exit", + description: "Graceful extension exit", + handler: async () => undefined, + }); + const extension = { + path: "/test/sf-ext", + handlers: new Map(), + commands, + shortcuts: [], + diagnostics: [], + } as unknown as Extension; + const runner = new ExtensionRunner( + [extension], + makeMinimalRuntime(), + dir, + sessionManager, + modelRegistry, + ); + + const commandsForAutocomplete = runner.getRegisteredCommands( + new Set(["exit"]), + new Set(["exit"]), + ); + + assert.deepEqual(commandsForAutocomplete, []); + assert.deepEqual(runner.getCommandDiagnostics(), []); + assert.equal( + runner.getCommand("exit")?.description, + "Graceful extension exit", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("registered_command_when_reserved_without_delegation_emits_conflict_warning", () => { + const dir = mkdtempSync(join(tmpdir(), "runner-command-test-")); + try { + const sessionManager = SessionManager.create(dir, dir); + const authStorage = AuthStorage.create(); + const modelRegistry = new ModelRegistry( + authStorage, + join(dir, "models.json"), + ); + const commands = new Map(); + commands.set("reload", { + name: "reload", + description: "Duplicate reload", + handler: async () => undefined, + }); + const extension = { + path: "/test/duplicate-ext", + handlers: new Map(), + commands, + shortcuts: [], + diagnostics: [], + } as unknown as Extension; + const runner = new ExtensionRunner( + [extension], + makeMinimalRuntime(), + dir, + sessionManager, + modelRegistry, + ); + + assert.deepEqual(runner.getRegisteredCommands(new Set(["reload"])), []); + assert.equal(runner.getCommandDiagnostics().length, 1); + assert.match( + runner.getCommandDiagnostics()[0]?.message ?? "", + /conflicts with built-in commands/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/pi-coding-agent/src/core/extensions/runner.ts b/packages/pi-coding-agent/src/core/extensions/runner.ts index 480798473..7699790da 100644 --- a/packages/pi-coding-agent/src/core/extensions/runner.ts +++ b/packages/pi-coding-agent/src/core/extensions/runner.ts @@ -509,7 +509,10 @@ export class ExtensionRunner { return undefined; } - getRegisteredCommands(reserved?: Set): RegisteredCommand[] { + getRegisteredCommands( + reserved?: Set, + delegatedReserved?: Set, + ): RegisteredCommand[] { this.commandDiagnostics = []; const commands: RegisteredCommand[] = []; @@ -517,6 +520,9 @@ export class ExtensionRunner { for (const ext of this.extensions) { for (const command of ext.commands.values()) { if (reserved?.has(command.name)) { + if (delegatedReserved?.has(command.name)) { + continue; + } const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`; this.commandDiagnostics.push({ type: "warning", diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index c2221b426..263e7b9fe 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -8,6 +8,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import { listDescendants } from "@singularity-forge/native"; import type { AgentMessage } from "@singularity-forge/pi-agent-core"; import type { @@ -156,6 +157,47 @@ type CompactionQueuedMessage = { mode: "steer" | "followUp"; }; +const INTERACTIVE_RELOAD_EXIT_CODE = 12; + +function firstExistingRuntimeFile(candidates: string[]): string | undefined { + return candidates.find((candidate) => fs.existsSync(candidate)); +} + +/** + * Hash runtime modules that cannot be hot-swapped safely inside an active TUI. + * + * Purpose: distinguish plain resource reloads from package/runtime updates that + * need a process restart so the next session uses fresh already-imported code. + * + * Consumer: handleReloadCommand() before it attempts an in-process reload. + */ +function computeInteractiveRuntimeFingerprint(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + const files = [ + firstExistingRuntimeFile([ + path.join(here, "interactive-mode.js"), + path.join(here, "interactive-mode.ts"), + ]), + firstExistingRuntimeFile([ + path.resolve(here, "../../core/extensions/runner.js"), + path.resolve(here, "../../core/extensions/runner.ts"), + ]), + firstExistingRuntimeFile([ + path.resolve(here, "slash-command-handlers.js"), + path.resolve(here, "slash-command-handlers.ts"), + ]), + ].filter((file): file is string => Boolean(file)); + + const hash = crypto.createHash("sha256"); + for (const file of files.sort()) { + hash.update(path.relative(here, file)); + hash.update("\0"); + hash.update(fs.readFileSync(file)); + hash.update("\0"); + } + return hash.digest("hex").slice(0, 16); +} + /** * Options for InteractiveMode initialization. */ @@ -202,6 +244,8 @@ export class InteractiveMode { private keybindings: KeybindingsManager; private version: string; private isInitialized = false; + private readonly processRestartFingerprint = + computeInteractiveRuntimeFingerprint(); private onInputCallback?: (text: string) => void; private loadingAnimation: Loader | undefined = undefined; private readonly defaultWorkingMessage = "Working..."; @@ -438,6 +482,7 @@ export class InteractiveMode { const extensionCommands: SlashCommand[] = ( this.session.extensionRunner?.getRegisteredCommands( builtinCommandNames, + new Set(["exit"]), ) ?? [] ).map((cmd) => ({ name: cmd.name, @@ -3707,6 +3752,15 @@ export class InteractiveMode { return; } + const currentFingerprint = computeInteractiveRuntimeFingerprint(); + if (currentFingerprint !== this.processRestartFingerprint) { + this.showStatus( + "Runtime changed on disk; restarting SF and resuming this session...", + ); + this.ui.requestRender(); + process.exit(INTERACTIVE_RELOAD_EXIT_CODE); + } + this.resetExtensionUI(); const loader = new BorderedLoader(