diff --git a/src/cli/App.tsx b/src/cli/App.tsx index aadc065..72e61f5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1303,9 +1303,12 @@ export default function App({ syncTrajectoryElapsedBase, ]); - // Run SessionStart hooks when agent becomes available + // SessionStart hook feedback to prepend to first user message + const sessionStartFeedbackRef = useRef([]); + + // Run SessionStart hooks when agent becomes available (not the "loading" placeholder) useEffect(() => { - if (agentId && !sessionHooksRanRef.current) { + if (agentId && agentId !== "loading" && !sessionHooksRanRef.current) { sessionHooksRanRef.current = true; // Determine if this is a new session or resumed const isNewSession = !initialConversationId; @@ -1314,9 +1317,16 @@ export default function App({ agentId, agentName ?? undefined, conversationIdRef.current ?? undefined, - ).catch(() => { - // Silently ignore hook errors - }); + ) + .then((result) => { + // Store feedback to prepend to first user message + if (result.feedback.length > 0) { + sessionStartFeedbackRef.current = result.feedback; + } + }) + .catch(() => { + // Silently ignore hook errors + }); } }, [agentId, agentName, initialConversationId]); @@ -5853,6 +5863,22 @@ export default function App({ // Reset turn counter for memory reminders turnCountRef.current = 0; + // Re-run SessionStart hooks for new conversation + sessionHooksRanRef.current = false; + runSessionStartHooks( + true, // isNewSession + agentId, + agentName ?? undefined, + conversation.id, + ) + .then((result) => { + if (result.feedback.length > 0) { + sessionStartFeedbackRef.current = result.feedback; + } + }) + .catch(() => {}); + sessionHooksRanRef.current = true; + // Update command with success buffersRef.current.byId.set(cmdId, { kind: "command", @@ -5922,6 +5948,22 @@ export default function App({ // Reset turn counter for memory reminders turnCountRef.current = 0; + // Re-run SessionStart hooks for new conversation + sessionHooksRanRef.current = false; + runSessionStartHooks( + true, // isNewSession + agentId, + agentName ?? undefined, + conversation.id, + ) + .then((result) => { + if (result.feedback.length > 0) { + sessionStartFeedbackRef.current = result.feedback; + } + }) + .catch(() => {}); + sessionHooksRanRef.current = true; + // Update command with success buffersRef.current.byId.set(cmdId, { kind: "command", @@ -7413,6 +7455,14 @@ ${SYSTEM_REMINDER_CLOSE}`; hasSentSessionContextRef.current = true; } + // Inject SessionStart hook feedback (stdout on exit 2) into first message only + let sessionStartHookFeedback = ""; + if (sessionStartFeedbackRef.current.length > 0) { + sessionStartHookFeedback = `${SYSTEM_REMINDER_OPEN}\n[SessionStart hook context]:\n${sessionStartFeedbackRef.current.join("\n")}\n${SYSTEM_REMINDER_CLOSE}\n\n`; + // Clear after injecting so it only happens once + sessionStartFeedbackRef.current = []; + } + // Build bash command prefix if there are cached commands let bashCommandPrefix = ""; if (bashCommandCacheRef.current.length > 0) { @@ -7489,9 +7539,10 @@ ${SYSTEM_REMINDER_CLOSE} lastNotifiedModeRef.current = currentMode; } - // Combine reminders with content (session context first, then permission mode, then plan mode, then ralph mode, then skill unload, then bash commands, then hook feedback, then memory reminder, then memfs conflicts) + // Combine reminders with content (session context first, then session start hook, then permission mode, then plan mode, then ralph mode, then skill unload, then bash commands, then hook feedback, then memory reminder, then memfs conflicts) const allReminders = sessionContextReminder + + sessionStartHookFeedback + permissionModeAlert + planModeReminder + ralphModeReminder + diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 39a8977..4de286b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -379,6 +379,8 @@ export async function runSetupHooks( /** * Run SessionStart hooks when a session begins + * Unlike other hooks, SessionStart collects stdout (not stderr) on exit 2 + * to inject context into the first user message */ export async function runSessionStartHooks( isNewSession: boolean, @@ -405,7 +407,23 @@ export async function runSessionStartHooks( conversation_id: conversationId, }; - return executeHooks(hooks, input, workingDirectory); + // Run hooks sequentially (SessionStart shouldn't block, but we collect feedback) + const result = await executeHooks(hooks, input, workingDirectory); + + // For SessionStart, collect stdout from all hooks regardless of exit code + const feedback: string[] = []; + for (const hookResult of result.results) { + if (hookResult.stdout?.trim()) { + feedback.push(hookResult.stdout.trim()); + } + } + + return { + blocked: false, // SessionStart never blocks + errored: result.errored, + feedback, + results: result.results, + }; } /** diff --git a/src/tests/hooks/integration.test.ts b/src/tests/hooks/integration.test.ts index 4603204..6c63cbb 100644 --- a/src/tests/hooks/integration.test.ts +++ b/src/tests/hooks/integration.test.ts @@ -955,6 +955,36 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => { expect(parsed.agent_id).toBe("agent-abc"); expect(parsed.agent_name).toBe("My Agent"); }); + + test("collects stdout as feedback regardless of exit code", async () => { + createHooksConfig({ + SessionStart: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: "echo 'Session context for agent'", + }, + ], + }, + ], + }); + + const result = await runSessionStartHooks( + true, + "agent-123", + "Test Agent", + undefined, + tempDir, + ); + + // SessionStart collects stdout regardless of exit code + expect(result.feedback).toHaveLength(1); + expect(result.feedback[0]).toContain("Session context for agent"); + // SessionStart never blocks + expect(result.blocked).toBe(false); + }); }); // ============================================================================