diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d512b80e0..f37f9ef64 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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"); + } } } diff --git a/packages/coding-agent/src/modes/print-mode.test.ts b/packages/coding-agent/src/modes/print-mode.test.ts index 957095513..8d2944304 100644 --- a/packages/coding-agent/src/modes/print-mode.test.ts +++ b/packages/coding-agent/src/modes/print-mode.test.ts @@ -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(() => {}), + 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; } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 09083c159..e163fa7a9 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -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 { diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 11440c10f..c9b704bd8 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -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( diff --git a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs index a20f846e4..62adfa218 100644 --- a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs +++ b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs @@ -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());