fix: keep hidden sf commands callable in print mode

This commit is contained in:
Mikael Hugo 2026-05-14 21:25:18 +02:00
parent ccdf530488
commit 2e4bdd292c
5 changed files with 66 additions and 13 deletions

View file

@ -229,6 +229,8 @@ export interface ExtensionBindings {
commandContextActions?: ExtensionCommandContextActions;
shutdownHandler?: ShutdownHandler;
onError?: ExtensionErrorListener;
/** When false, bind runtime contexts without firing session_start/resources_discover. */
runLifecycle?: boolean;
}
/** Options for AgentSession.prompt() */
@ -2227,8 +2229,10 @@ export class AgentSession {
if (this._extensionRunner) {
this._applyExtensionBindings(this._extensionRunner);
await this._extensionRunner.emit({ type: "session_start" });
await this.extendResourcesFromExtensions("startup");
if (bindings.runLifecycle !== false) {
await this._extensionRunner.emit({ type: "session_start" });
await this.extendResourcesFromExtensions("startup");
}
}
}

View file

@ -47,6 +47,7 @@ test("promptWithPrintTimeout_when_prompt_settles_clears_watchdog", async () => {
test("runPrintMode_when_extension_startup_hangs_still_runs_prompt", async () => {
const logs: string[] = [];
let lifecycleDisabled = false;
const originalLog = console.log;
console.log = (message?: unknown) => {
logs.push(String(message));
@ -54,7 +55,9 @@ test("runPrintMode_when_extension_startup_hangs_still_runs_prompt", async () =>
try {
const session = {
sessionManager: { getHeader: () => null },
bindExtensions: () => new Promise<void>(() => {}),
bindExtensions: async (bindings: { runLifecycle?: boolean }) => {
lifecycleDisabled = bindings.runLifecycle === false;
},
subscribe: () => () => undefined,
prompt: async () => {},
abort: async () => {},
@ -76,6 +79,7 @@ test("runPrintMode_when_extension_startup_hangs_still_runs_prompt", async () =>
});
assert.deepEqual(logs, ["hi"]);
assert.equal(lifecycleDisabled, true);
} finally {
console.log = originalLog;
}

View file

@ -9,6 +9,9 @@
import { type ChildProcess, spawn } from "node:child_process";
import type { AssistantMessage, ImageContent } from "@singularity-forge/ai";
import type { AgentSession } from "../core/agent-session.js";
import type { ExtensionUIContext } from "../core/extensions/types.js";
import { theme } from "./interactive/theme/theme.js";
import { createDefaultCommandContextActions } from "./shared/command-context-actions.js";
/**
* Options for print mode.
@ -123,9 +126,18 @@ export async function runPrintMode(
console.log(JSON.stringify(header));
}
}
// Print mode intentionally skips extension session_start binding. One-shot
// automation needs bounded prompt output; startup hooks are interactive/RPC
// lifecycle work and have previously blocked `sf -p` before the prompt ran.
// Bind command context so `/todo ...` and other extension slash commands work
// in print mode, but skip session_start/resources_discover lifecycle work.
// Those startup hooks are interactive/RPC concerns and have previously blocked
// `sf -p` before the prompt ran.
await session.bindExtensions({
commandContextActions: createDefaultCommandContextActions(session),
uiContext: createPrintModeUIContext(),
onError: (err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
},
runLifecycle: false,
});
const liveness = createPrintModeLivenessReporter(mode);
// Always subscribe to enable session persistence via _handleAgentEvent
@ -235,6 +247,39 @@ export async function runPrintMode(
}
}
function createPrintModeUIContext(): ExtensionUIContext {
return {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: (message: string) => {
process.stdout.write(message.endsWith("\n") ? message : `${message}\n`);
},
onTerminalInput: () => () => {},
setStatus: () => {},
setWorkingMessage: () => {},
setWorkingVisible: () => {},
setWidget: () => {},
setFooter: () => {},
setHeader: () => {},
setTitle: () => {},
custom: async () => undefined as never,
pasteToEditor: () => {},
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
setEditorComponent: () => {},
get theme() {
return theme;
},
getAllThemes: () => [],
getTheme: () => undefined,
setTheme: () => ({ success: false, error: "UI not available" }),
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
}
export function createPrintModeLivenessReporter(
mode: "text" | "json",
): (event: { type: string; assistantMessageEvent?: { type: string } }) => void {

View file

@ -321,7 +321,8 @@ export const TOP_LEVEL_SUBCOMMANDS = [
// pick workflows, not personas, and SF runs implementation machinery (agent
// picks, mode/work-mode shifts, orchestration internals) on its own. Commands
// not in this set stay callable for scripting/debug but don't show up in the
// slash catalog or help.
// slash catalog or help. Registration uses DIRECT_SF_COMMANDS below; visibility
// uses PUBLIC_DIRECT_COMMANDS.
//
// Hidden by category:
// - /agent, /parallel, /cmux, /sidekicks — internal orchestration machinery
@ -396,9 +397,7 @@ export const PUBLIC_TOP_LEVEL_SUBCOMMANDS = TOP_LEVEL_SUBCOMMANDS.filter(
);
export const DIRECT_SF_COMMANDS = TOP_LEVEL_SUBCOMMANDS.filter(
(command) =>
PUBLIC_DIRECT_COMMANDS.has(command.cmd) &&
!BASE_RUNTIME_COMMANDS.has(command.cmd),
(command) => !BASE_RUNTIME_COMMANDS.has(command.cmd),
);
export const DIRECT_SF_COMMAND_NAMES = DIRECT_SF_COMMANDS.map(

View file

@ -20,7 +20,7 @@ import { showHelp } from "../commands/handlers/core.js";
import { registerSFCommands } from "../commands/index.js";
import { queryJournal } from "../journal.js";
test("direct SF command surface registers workflow verbs without legacy sf namespace", () => {
test("direct SF command surface registers callable verbs without legacy sf namespace", () => {
const registered = [];
const pi = {
registerCommand(name, options) {
@ -38,9 +38,10 @@ test("direct SF command surface registers workflow verbs without legacy sf names
assert.ok(names.includes("doctor"));
assert.ok(names.includes("status"));
assert.ok(names.includes("ship"));
assert.equal(names.includes("plan"), false);
assert.ok(names.includes("plan"));
assert.ok(names.includes("todo"));
assert.equal(names.includes("model"), false);
assert.equal(names.includes("permission-profile"), false);
assert.ok(names.includes("permission-profile"));
assert.ok(!names.includes("sf"));
assert.ok(!names.includes("stop"));
assert.deepEqual(names, [...DIRECT_SF_COMMAND_NAMES].sort());