diff --git a/electron/agent-hooks.mjs b/electron/agent-hooks.mjs index 9741bb1..a607dcb 100644 --- a/electron/agent-hooks.mjs +++ b/electron/agent-hooks.mjs @@ -30,10 +30,12 @@ export function registerAgentHookIpc() { if (!pending || pending.webContentsId !== event.sender.id) { return { status: "ignored" }; } + const result = normalizeAgentHookListenerResult(payload?.result); clearTimeout(pending.timeout); pendingDeliveries.delete(deliveryId); pending.resolve({ status: payload?.status === "error" ? "error" : "ok", + ...result, }); return { status: "acknowledged" }; }); @@ -270,6 +272,9 @@ async function processAgentHookMessage(endpoint, input) { return { ok: delivery.status !== "timeout" && delivery.status !== "disposed", status: delivery.status, + ...(delivery.additionalContext + ? { additionalContext: delivery.additionalContext } + : {}), }; } @@ -359,6 +364,27 @@ function parseHookStdin(value) { } } +function normalizeAgentHookListenerResult(value) { + if (typeof value === "string") { + return normalizeAgentHookAdditionalContext(value); + } + if (!value || typeof value !== "object") { + return {}; + } + return normalizeAgentHookAdditionalContext(value.additionalContext); +} + +function normalizeAgentHookAdditionalContext(value) { + if (typeof value !== "string") { + return {}; + } + const additionalContext = value.trim(); + if (!additionalContext) { + return {}; + } + return { additionalContext }; +} + export function agentHookScriptSource() { return String.raw`import { readFileSync } from "node:fs"; import { connect } from "node:net"; @@ -383,7 +409,15 @@ const payload = { }; try { - await sendHookEvent(payload); + const response = await sendHookEvent(payload); + const additionalContext = readAdditionalContext(response); + if (payload.phase === "turn-start" && additionalContext) { + process.stdout.write( + additionalContext.endsWith("\n") + ? additionalContext + : additionalContext + "\n", + ); + } } catch { // Hook delivery must not prevent the agent command from continuing. } @@ -393,7 +427,7 @@ function sendHookEvent(payload) { const socket = connect(socketPath); let response = ""; let settled = false; - const finish = (error) => { + const finish = (error, value) => { if (settled) return; settled = true; socket.destroy(); @@ -401,7 +435,7 @@ function sendHookEvent(payload) { reject(error); return; } - resolve(); + resolve(value); }; socket.setEncoding("utf8"); @@ -419,13 +453,14 @@ function sendHookEvent(payload) { finish(new Error("Hook acknowledgement was empty")); return; } + let parsed; try { - JSON.parse(response); + parsed = JSON.parse(response); } catch { finish(new Error("Hook acknowledgement was invalid JSON")); return; } - finish(); + finish(null, parsed); }); socket.on("error", finish); socket.on("close", () => { @@ -434,5 +469,12 @@ function sendHookEvent(payload) { }); } +function readAdditionalContext(response) { + const value = response?.additionalContext; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + `; } diff --git a/electron/agent-hooks.test.mjs b/electron/agent-hooks.test.mjs index 2720f4c..80b8c44 100644 --- a/electron/agent-hooks.test.mjs +++ b/electron/agent-hooks.test.mjs @@ -198,6 +198,55 @@ describe("agentHookScriptSource", () => { } }); + test("prints additional context from turn-start acknowledgements", async () => { + const rootDir = await mkdtemp(path.join(tmpdir(), "flashtype-agent-hook-")); + const socketPath = testSocketPath(rootDir); + const received = deferred(); + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + let input = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + input += chunk; + }); + socket.on("end", () => { + received.resolve(JSON.parse(input)); + socket.end( + `${JSON.stringify({ + ok: true, + additionalContext: "The active file right now is ./current.md", + })}\n`, + ); + }); + }); + try { + const scriptPath = path.join(rootDir, "hook.mjs"); + await writeFile(scriptPath, agentHookScriptSource(), { mode: 0o700 }); + await listen(server, socketPath); + + const result = await runHookScript({ + scriptPath, + args: ["codex", "turn-start"], + env: { + FLASHTYPE_AGENT_HOOK_SOCKET: socketPath, + FLASHTYPE_AGENT_HOOK_TOKEN: "secret", + FLASHTYPE_AGENT_HOOK_INSTANCE_ID: "terminal:1", + }, + stdin: JSON.stringify({ + hook_event_name: "UserPromptSubmit", + }), + }); + + expect(await received.promise).toMatchObject({ + agent: "codex", + phase: "turn-start", + }); + expect(result.stdout).toBe("The active file right now is ./current.md\n"); + } finally { + server.close(); + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("exits successfully when socket delivery fails", async () => { const rootDir = await mkdtemp(path.join(tmpdir(), "flashtype-agent-hook-")); try { @@ -280,7 +329,7 @@ function runHookScript({ scriptPath, args, env, stdin }) { child.on("error", reject); child.on("close", (code, signal) => { if (code === 0) { - resolve(); + resolve({ stdout, stderr }); return; } reject( diff --git a/electron/preload.mjs b/electron/preload.mjs index 8aa2e23..819dbc1 100644 --- a/electron/preload.mjs +++ b/electron/preload.mjs @@ -119,9 +119,10 @@ const agentHooks = { return; } void Promise.resolve(listener(payload.event)) - .then(() => + .then((result) => ipcRenderer.invoke("agentHooks:completeTurnEvent", { deliveryId: payload.deliveryId, + result: normalizeAgentHookListenerResult(result), status: "ok", }), ) @@ -140,6 +141,20 @@ const agentHooks = { }, }; +function normalizeAgentHookListenerResult(value) { + if (typeof value === "string") { + return { additionalContext: value }; + } + if (!value || typeof value !== "object") { + return undefined; + } + const additionalContext = value.additionalContext; + if (typeof additionalContext !== "string") { + return undefined; + } + return { additionalContext }; +} + contextBridge.exposeInMainWorld("flashtypeDesktop", { agentHooks, app, diff --git a/electron/types.d.ts b/electron/types.d.ts index 0fe66af..c178b83 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -147,9 +147,18 @@ export type DesktopAgentTurnEvent = { createdAt: number; }; +export type DesktopAgentTurnEventResult = + | void + | string + | { + additionalContext?: string | null; + }; + export type DesktopAgentHooksApi = { onTurnEvent( - listener: (event: DesktopAgentTurnEvent) => void | Promise, + listener: ( + event: DesktopAgentTurnEvent, + ) => DesktopAgentTurnEventResult | Promise, ): () => void; }; diff --git a/src/shell/agent-launch.test.ts b/src/shell/agent-launch.test.ts index c7d5001..67b71de 100644 --- a/src/shell/agent-launch.test.ts +++ b/src/shell/agent-launch.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + FLASHTYPE_INITIAL_PROMPT, TERMINAL_INITIAL_COMMAND_LAUNCH_ARG, buildAgentLaunchArgsWithActiveFile, buildFlashtypeActiveFilePrompt, @@ -11,9 +12,9 @@ import { } from "@/extension-runtime/agent-terminal-command"; describe("buildFlashtypeActiveFilePrompt", () => { - test("uses the Flashtype.com launch-context sentence", () => { + test("uses the active-file context sentence", () => { expect(buildFlashtypeActiveFilePrompt("/docs/intro.md")).toBe( - "The user is using Flashtype.com. The active file right now, which may change later, is: ./docs/intro.md", + "The current document is: ./docs/intro.md", ); }); @@ -29,7 +30,7 @@ describe("buildFlashtypeActiveFilePrompt", () => { }); describe("buildAgentLaunchArgsWithActiveFile", () => { - test("adds a Claude append-system-prompt launch command", () => { + test("adds Claude hook launch args with Flashtype system prompt only", () => { const launchArgs = buildAgentLaunchArgsWithActiveFile({ state: { command: "claude --dangerously-skip-permissions", @@ -56,11 +57,12 @@ describe("buildAgentLaunchArgsWithActiveFile", () => { 'ELECTRON_RUN_AS_NODE=1 \\"$FLASHTYPE_AGENT_HOOK_NODE\\" \\"$FLASHTYPE_AGENT_HOOK_SCRIPT\\" claude turn-start', ); expect(pathWrapper.command).toContain( - "--append-system-prompt 'The user is using Flashtype.com. The active file right now, which may change later, is: ./docs/intro.md'", + `--append-system-prompt '${FLASHTYPE_INITIAL_PROMPT}'`, ); + expect(pathWrapper.command).not.toContain("./docs/intro.md"); }); - test("adds a Codex developer-instructions launch command", () => { + test("adds Codex hook launch args with Flashtype developer instructions only", () => { const launchArgs = buildAgentLaunchArgsWithActiveFile({ state: { command: "codex --dangerously-bypass-approvals-and-sandbox", @@ -86,8 +88,9 @@ describe("buildAgentLaunchArgsWithActiveFile", () => { 'ELECTRON_RUN_AS_NODE=1 \\"$FLASHTYPE_AGENT_HOOK_NODE\\" \\"$FLASHTYPE_AGENT_HOOK_SCRIPT\\" codex turn-start', ); expect(pathWrapper.command).toContain( - "-c 'developer_instructions=\"The user is using Flashtype.com. The active file right now, which may change later, is: ./docs/intro.md\"'", + `-c 'developer_instructions="${FLASHTYPE_INITIAL_PROMPT}"'`, ); + expect(pathWrapper.command).not.toContain("./docs/intro.md"); }); test("injects hooks for agent launches without an active file", () => { @@ -105,7 +108,9 @@ describe("buildAgentLaunchArgsWithActiveFile", () => { }; expect(command).toBe("codex-flashtype"); expect(pathWrapper.command).toContain("hooks.UserPromptSubmit="); - expect(pathWrapper.command).not.toContain("developer_instructions="); + expect(pathWrapper.command).toContain( + `developer_instructions="${FLASHTYPE_INITIAL_PROMPT}"`, + ); }); test("injects hooks into restored agent terminal commands", () => { diff --git a/src/shell/agent-launch.ts b/src/shell/agent-launch.ts index 1348ada..6460ae3 100644 --- a/src/shell/agent-launch.ts +++ b/src/shell/agent-launch.ts @@ -9,12 +9,17 @@ import { export { TERMINAL_INITIAL_COMMAND_LAUNCH_ARG }; +export const FLASHTYPE_INITIAL_PROMPT = + "You are running inside Flashtype, a local Markdown editor with inline diff review. Use workspace files as the source of truth. Make requested changes; the user reviews diffs before they land."; + export function buildAgentLaunchArgsWithActiveFile(args: { readonly state?: ExtensionState; readonly activeFilePath?: string | null; }): ExtensionLaunchArgs | undefined { - const prompt = buildFlashtypeActiveFilePrompt(args.activeFilePath); - return buildAgentTerminalLaunchArgs({ state: args.state, prompt }); + return buildAgentTerminalLaunchArgs({ + state: args.state, + prompt: FLASHTYPE_INITIAL_PROMPT, + }); } export function buildFlashtypeActiveFilePrompt( @@ -24,7 +29,7 @@ export function buildFlashtypeActiveFilePrompt( if (!promptPath) { return null; } - return `The user is using Flashtype.com. The active file right now, which may change later, is: ${promptPath}`; + return `The current document is: ${promptPath}`; } function normalizePromptFilePath( diff --git a/src/shell/layout-shell.test.tsx b/src/shell/layout-shell.test.tsx index 900f3fe..2454457 100644 --- a/src/shell/layout-shell.test.tsx +++ b/src/shell/layout-shell.test.tsx @@ -21,7 +21,7 @@ type DesktopMock = { }; type AgentHooksDesktopMock = { - readonly emitTurnEvent: (event: AgentHookTurnEventInput) => Promise; + readonly emitTurnEvent: (event: AgentHookTurnEventInput) => Promise; readonly onTurnEvent: ReturnType; readonly setActiveFilePath: ReturnType; }; @@ -399,6 +399,32 @@ describe("V2LayoutShell active file sidebar highlight", () => { }); describe("V2LayoutShell agent review auto-open", () => { + test("returns current active file context from turn-start hooks", async () => { + const desktop = installAgentHooksDesktopMock(); + const lix = await openLix({ + keyValues: [ + uiStateKeyValue(openFileState("file_current", "/current.md")), + ], + }); + vi.spyOn(lix, "syncDiskToLix").mockResolvedValue(); + await writeReviewFile(lix, "file_current", "/current.md", "# Current"); + + const utils = await renderShell(lix); + await waitFor(() => expect(desktop.onTurnEvent).toHaveBeenCalled()); + + let result: unknown; + await act(async () => { + result = await desktop.emitTurnEvent(agentTurnEvent("turn-start")); + }); + + expect(result).toEqual({ + additionalContext: "The current document is: ./current.md", + }); + + await unmountShell(utils); + await lix.close(); + }); + test("leaves the current file open when it already has a pending review", async () => { const desktop = installAgentHooksDesktopMock(); const lix = await openLix({ @@ -550,9 +576,9 @@ function installDesktopMock(): DesktopMock { } function installAgentHooksDesktopMock(): AgentHooksDesktopMock { - let listener: ((event: unknown) => void | Promise) | null = null; + let listener: ((event: unknown) => unknown | Promise) | null = null; const onTurnEvent = vi.fn( - (nextListener: (event: unknown) => void | Promise) => { + (nextListener: (event: unknown) => unknown | Promise) => { listener = nextListener; return () => { if (listener === nextListener) { @@ -576,7 +602,7 @@ function installAgentHooksDesktopMock(): AgentHooksDesktopMock { if (!listener) { throw new Error("agent hook listener was not registered"); } - await listener(event); + return await listener(event); }, onTurnEvent, setActiveFilePath, diff --git a/src/shell/layout-shell.tsx b/src/shell/layout-shell.tsx index f4a4320..83bea3f 100644 --- a/src/shell/layout-shell.tsx +++ b/src/shell/layout-shell.tsx @@ -88,7 +88,10 @@ import { cloneExtensionInstance, reorderPanelExtensionsByIndex, } from "./panel-utils"; -import { buildAgentLaunchArgsWithActiveFile } from "./agent-launch"; +import { + buildAgentLaunchArgsWithActiveFile, + buildFlashtypeActiveFilePrompt, +} from "./agent-launch"; import { clearAgentTurnCommitRangeFile, appendAgentTurnCommitRange, @@ -125,6 +128,10 @@ type ActiveAgentTurn = { readonly beforeCommitIdPromise: Promise; }; +type AgentHookTurnEventResult = void | { + readonly additionalContext?: string | null; +}; + const stripLaunchArgs = (view: ExtensionInstance): ExtensionInstance => { const { launchArgs: _omitLaunch, ...rest } = view as any; const state = sanitizeExtensionStateForPersistence(rest.state); @@ -190,6 +197,18 @@ const isPlainObject = (value: unknown): value is Record => { return prototype === Object.prototype || prototype === null; }; +const activeEntryFromPanel = (panel: PanelState): ExtensionInstance | null => { + const activeInstance = + panel.activeInstance ?? panel.views[0]?.instance ?? null; + if (!activeInstance) return null; + return panel.views.find((entry) => entry.instance === activeInstance) ?? null; +}; + +const activeFilePathFromPanel = (panel: PanelState): string | null => { + const rawPath = activeEntryFromPanel(panel)?.state?.filePath; + return typeof rawPath === "string" && rawPath.length > 0 ? rawPath : null; +}; + const newFileDraftHandlerKey = ( registration: NewFileDraftHandlerRegistration, ): string => `${registration.panelSide}:${registration.viewInstance}`; @@ -848,9 +867,12 @@ function LayoutShellLoadedContent({ resolveDiffReviewTelemetryRef.current = resolveDiffReviewTelemetry; const handleAgentHookTurnEvent = useCallback( - async (event: AgentHookTurnEvent) => { + async (event: AgentHookTurnEvent): Promise => { const key = agentTurnKey(event); if (event.phase === "turn-start") { + const additionalContext = buildFlashtypeActiveFilePrompt( + activeFilePathFromPanel(panelStatesRef.current.central), + ); const beforeCommitIdPromise = readSyncedActiveCommitId(lix).catch( (error: unknown) => { console.warn( @@ -866,7 +888,7 @@ function LayoutShellLoadedContent({ beforeCommitIdPromise, }); await beforeCommitIdPromise; - return; + return additionalContext ? { additionalContext } : undefined; } try { @@ -1835,13 +1857,7 @@ function LayoutShellLoadedContent({ ); const activeCentralEntry = useMemo(() => { - const activeInstance = - centralPanel.activeInstance ?? centralPanel.views[0]?.instance ?? null; - if (!activeInstance) return null; - return ( - centralPanel.views.find((entry) => entry.instance === activeInstance) ?? - null - ); + return activeEntryFromPanel(centralPanel); }, [centralPanel]); const handleAddView = useCallback( @@ -2148,10 +2164,8 @@ function LayoutShellLoadedContent({ }, [activeCentralEntry, extensionMap]); const activeFilePath = useMemo(() => { - if (!activeCentralEntry) return null; - const rawPath = activeCentralEntry.state?.filePath; - return typeof rawPath === "string" && rawPath.length > 0 ? rawPath : null; - }, [activeCentralEntry]); + return activeFilePathFromPanel(centralPanel); + }, [centralPanel]); useEffect(() => { void window.flashtypeDesktop?.workspace.setActiveFilePath({