fix: reload sf source runtime on extension changes

This commit is contained in:
Mikael Hugo 2026-05-07 10:31:34 +02:00
parent 343ee5c89e
commit 426fea7334
4 changed files with 170 additions and 6 deletions

View file

@ -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

View file

@ -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 });
}
});
});

View file

@ -509,7 +509,10 @@ export class ExtensionRunner {
return undefined;
}
getRegisteredCommands(reserved?: Set<string>): RegisteredCommand[] {
getRegisteredCommands(
reserved?: Set<string>,
delegatedReserved?: Set<string>,
): 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",

View file

@ -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(