From 8fb849996d12bded189872a23af4425d8dc6a111 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 24 May 2026 12:17:20 -0700 Subject: [PATCH 1/2] feat(agents): add Grok adapter and login terminal overlay - Implement Grok CLI detection, argv mapping, session files parser, and adapter integration - Add interactive LoginTerminalOverlay and zustand store for TUI-based logins - Extract base agent filesystem utilities into shared sessionFs module - Update Antigravity, Gemini, Codex, and others to use the new sessionFs - Enhance ACP permission mapping and runtime output pipeline for tool approvals --- .agents/docs/agent-adapters.md | 1 + scripts/prepare-agent-plugins.mjs | 6 + src/renderer/actions/agentLoginActions.ts | 79 +++- .../components/providers/grok/GrokIcon.tsx | 10 + .../components/providers/grok/index.tsx | 54 +++ src/renderer/components/providers/index.ts | 1 + .../components/thread/ChatPane/ChatPane.tsx | 10 +- .../ThreadRuntimeRequestPanel.tsx | 4 +- .../ThreadRuntimeRequestPanel/helpers.ts | 1 + .../parts/QuestionRows.tsx | 1 + src/renderer/state/loginTerminalStore.ts | 31 ++ .../LoginTerminalOverlay.tsx | 139 +++++++ .../views/MainView/parts/AppOverlays.tsx | 2 + .../parts/SingleAgentSettings.tsx | 61 ++- src/shared/contracts/runtimeEvent.ts | 1 + .../agents/acp/canonicalMapping.test.ts | 45 ++ src/supervisor/agents/acp/canonicalMapping.ts | 23 +- src/supervisor/agents/acp/probe.ts | 59 +++ src/supervisor/agents/acp/session.test.ts | 65 +++ src/supervisor/agents/acp/session.ts | 41 +- src/supervisor/agents/antigravity/index.ts | 13 +- .../agents/antigravity/session.test.ts | 4 +- src/supervisor/agents/antigravity/session.ts | 35 +- src/supervisor/agents/base/index.ts | 19 +- src/supervisor/agents/base/sessionFs.ts | 341 ++++++++++++++++ src/supervisor/agents/base/types.ts | 1 + .../agents/claude/plugin/plugin.json | 2 +- src/supervisor/agents/codex/index.ts | 25 +- .../agents/codex/plugin/plugin.json | 2 +- src/supervisor/agents/codex/session.ts | 102 ++++- src/supervisor/agents/copilot/argv.ts | 8 +- .../agents/copilot/plugin/plugin.json | 2 +- .../agents/cursor/plugin/install.test.ts | 2 +- .../agents/cursor/plugin/plugin.json | 2 +- .../agents/gemini/detection.test.ts | 6 +- src/supervisor/agents/gemini/detection.ts | 28 +- src/supervisor/agents/gemini/gemini.test.ts | 2 +- .../agents/gemini/plugin/install.test.ts | 2 +- .../agents/gemini/plugin/plugin.json | 2 +- src/supervisor/agents/grok/argv.test.ts | 84 ++++ src/supervisor/agents/grok/argv.ts | 89 ++++ src/supervisor/agents/grok/detection.ts | 176 ++++++++ src/supervisor/agents/grok/grok.test.ts | 98 +++++ src/supervisor/agents/grok/index.ts | 192 +++++++++ src/supervisor/agents/grok/plugin/forward.mjs | 97 +++++ .../agents/grok/plugin/install.test.ts | 181 +++++++++ src/supervisor/agents/grok/plugin/install.ts | 383 ++++++++++++++++++ .../agents/grok/plugin/intentMap.ts | 62 +++ src/supervisor/agents/grok/plugin/plugin.json | 7 + src/supervisor/agents/grok/sessionFiles.ts | 291 +++++++++++++ .../lightcode-hook-runtime.mjs | 63 ++- src/supervisor/agents/registry.ts | 2 + src/supervisor/runtime.ts | 8 +- .../runtime/threadOutputPipeline.ts | 10 + 54 files changed, 2866 insertions(+), 109 deletions(-) create mode 100644 src/renderer/components/providers/grok/GrokIcon.tsx create mode 100644 src/renderer/components/providers/grok/index.tsx create mode 100644 src/renderer/state/loginTerminalStore.ts create mode 100644 src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx create mode 100644 src/supervisor/agents/base/sessionFs.ts create mode 100644 src/supervisor/agents/grok/argv.test.ts create mode 100644 src/supervisor/agents/grok/argv.ts create mode 100644 src/supervisor/agents/grok/detection.ts create mode 100644 src/supervisor/agents/grok/grok.test.ts create mode 100644 src/supervisor/agents/grok/index.ts create mode 100644 src/supervisor/agents/grok/plugin/forward.mjs create mode 100644 src/supervisor/agents/grok/plugin/install.test.ts create mode 100644 src/supervisor/agents/grok/plugin/install.ts create mode 100644 src/supervisor/agents/grok/plugin/intentMap.ts create mode 100644 src/supervisor/agents/grok/plugin/plugin.json create mode 100644 src/supervisor/agents/grok/sessionFiles.ts diff --git a/.agents/docs/agent-adapters.md b/.agents/docs/agent-adapters.md index 92b61956..91060ce1 100644 --- a/.agents/docs/agent-adapters.md +++ b/.agents/docs/agent-adapters.md @@ -56,6 +56,7 @@ Opening two provider folders side-by-side answers "what does this provider do di | Gemini | auto, gemini-3.1-pro-preview, gemini-2.5-pro/flash/flash-lite, etc. | (none) | terminal | No | | Copilot | (probed via ACP) | low, medium, high, xhigh | terminal | Yes (ACP) | | Cursor | auto, composer-\*, GPT/Opus/Sonnet variants | (embedded in model name) | terminal | No | +| Grok | grok-build (probed via ACP) | (none) | terminal | Yes (ACP) | ## Plugin Architecture diff --git a/scripts/prepare-agent-plugins.mjs b/scripts/prepare-agent-plugins.mjs index c661a118..6f78b817 100644 --- a/scripts/prepare-agent-plugins.mjs +++ b/scripts/prepare-agent-plugins.mjs @@ -17,6 +17,7 @@ * - cursor: plugin.json, forward.mjs * - gemini: plugin.json, forward.mjs * - copilot: plugin.json, forward.mjs + * - grok: plugin.json, forward.mjs * - opencode: plugin.json, lightcode-status.mjs (in-process plugin, no forward.mjs) * * Plus a shared forwarder runtime under `_runtime/lightcode-hook-runtime.mjs` @@ -63,6 +64,11 @@ const PLUGINS = [ assets: ["plugin.json", "forward.mjs"], srcDir: join(repoRoot, "src", "supervisor", "agents", "copilot", "plugin"), }, + { + kind: "grok", + assets: ["plugin.json", "forward.mjs"], + srcDir: join(repoRoot, "src", "supervisor", "agents", "grok", "plugin"), + }, { kind: "opencode", assets: ["plugin.json", "lightcode-status.mjs"], diff --git a/src/renderer/actions/agentLoginActions.ts b/src/renderer/actions/agentLoginActions.ts index 4291d433..fdd59e14 100644 --- a/src/renderer/actions/agentLoginActions.ts +++ b/src/renderer/actions/agentLoginActions.ts @@ -3,6 +3,7 @@ import type { Project } from "@/shared/contracts"; import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "@/renderer/state/appStore"; import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; +import { useLoginTerminalStore } from "@/renderer/state/loginTerminalStore"; import { usePanelStore } from "@/renderer/state/panelStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { writeScriptToShell } from "@/renderer/utils/shellUtils"; @@ -98,16 +99,70 @@ export function runAgentLoginCommand(input: { onCommandComplete?: (exitCode: number) => void; project?: Project; }): boolean { - return runAgentTerminalCommand({ - label: input.label, - command: input.command, - ...(input.env ? { env: input.env } : {}), - ...(input.onCommandComplete ? { onCommandComplete: input.onCommandComplete } : {}), - ...(input.project ? { project: input.project } : {}), + const project = input.project ?? resolveLoginProject(); + if (!project) { + toast.warning("Add a project before signing in."); + return false; + } + + // Replace any active login session — only one terminal panel at a time. + const previous = useLoginTerminalStore.getState().active; + if (previous) { + previous.onForceClose?.(); + void readBridge() + .closeThread({ threadId: previous.shellId }) + .catch(() => undefined); + } + + const shellId = `login:${crypto.randomUUID()}`; + // Wipe the bash prompt + echoed script line that briefly appear before the + // TUI takes over. `clear` (POSIX) / `Clear-Host` (PowerShell) gives the + // overlay a clean canvas so the user only sees the agent's own UI. + const cleared = + project.location.kind === "windows" + ? `Clear-Host; ${input.command}` + : `clear; ${input.command}`; + const command = buildTerminalCommand({ + command: cleared, + env: input.env, openUrlsInNativeBrowser: true, - tabPurpose: "login", - toastPurpose: "login", + project, }); + const completionToken = createCompletionToken(); + const script = appendCompletionSignal(command, project, completionToken); + + let fired = false; + const fireOnce = (exitCode: number) => { + if (fired) return; + fired = true; + input.onCommandComplete?.(exitCode); + }; + + const stopWatching = watchCommandCompletion(shellId, completionToken, (exitCode) => { + fireOnce(exitCode); + // Auto-dismiss the overlay shortly after the command exits so the user + // can read any final success line before it slides away. + window.setTimeout(() => useLoginTerminalStore.getState().close(), 1200); + }); + + useLoginTerminalStore.getState().open({ + shellId, + label: input.label, + projectLocation: project.location, + onForceClose: () => { + stopWatching(); + fireOnce(-1); + }, + }); + + void readBridge() + .startShell({ shellId, projectLocation: project.location }) + .catch((error) => { + toast.danger(error instanceof Error ? error.message : `Unable to open ${input.label} login.`); + useLoginTerminalStore.getState().close(); + }); + writeScriptToShell(shellId, script); + return true; } function quotePosixShellArg(value: string): string { @@ -165,7 +220,7 @@ function watchCommandCompletion( shellId: string, token: string, onCommandComplete: (exitCode: number) => void, -): void { +): () => void { const marker = completionMarker(token); let buffer = ""; let done = false; @@ -188,4 +243,10 @@ function watchCommandCompletion( unsubscribe(); onCommandComplete(Number(match[1])); }); + return () => { + if (done) return; + done = true; + window.clearTimeout(timeout); + unsubscribe(); + }; } diff --git a/src/renderer/components/providers/grok/GrokIcon.tsx b/src/renderer/components/providers/grok/GrokIcon.tsx new file mode 100644 index 00000000..476f1595 --- /dev/null +++ b/src/renderer/components/providers/grok/GrokIcon.tsx @@ -0,0 +1,10 @@ +import { createProviderIcon } from "../common/createProviderIcon"; + +const GROK_PATH = + "M2.30047 8.77631L12.0474 23H16.3799L6.63183 8.77631H2.30047ZM6.6285 16.6762L2.29492 23H6.63072L8.79584 19.8387L6.6285 16.6762ZM17.3709 1L9.88007 11.9308L12.0474 15.0944L21.7067 1H17.3709ZM18.1555 7.76374V23H21.7067V2.5818L18.1555 7.76374Z"; + +export const GrokIcon = createProviderIcon({ + cssPrefix: "lightcode-grok-icon", + path: GROK_PATH, + viewBox: "0 0 24 24", +}); diff --git a/src/renderer/components/providers/grok/index.tsx b/src/renderer/components/providers/grok/index.tsx new file mode 100644 index 00000000..63a8bf85 --- /dev/null +++ b/src/renderer/components/providers/grok/index.tsx @@ -0,0 +1,54 @@ +export * from "./GrokIcon"; + +import { GrokIcon } from "./GrokIcon"; +import { fullAccessToggle } from "../composerControlBuilders"; +import { + registerCommitGenDefaults, + registerComposerControls, + registerConflictResolverDefaults, + registerProviderIcon, + registerProviderLabel, + registerTitleGenDefaults, +} from "../ProviderIcon"; + +registerProviderIcon("grok", GrokIcon); +registerProviderLabel("grok", "Grok Build"); + +registerCommitGenDefaults("grok", { + label: "Grok", + hint: "Build", + model: "grok-build", + effort: "", +}); + +registerTitleGenDefaults("grok", { + label: "Grok", + hint: "Build", + model: "grok-build", + effort: "", +}); + +registerConflictResolverDefaults("grok", { + label: "Grok", + hint: "Build", + model: "grok-build", + effort: "", +}); + +// Composer surface for Grok: a single Default ↔ Bypass Approvals toggle. +// Plan mode and effort are intentionally absent — neither is driveable from +// launch flags on Grok 0.1.218. See `supervisor/agents/grok/detection.ts` +// and `supervisor/agents/grok/argv.ts` for the rationale. +registerComposerControls("grok", ({ capabilities, config, isDisabled, onConfigChange }) => { + if (!capabilities.approvalPolicies?.length) return []; + const bypassPolicy = capabilities.bypassPermissions?.approvalPolicy ?? "bypassPermissions"; + const isBypass = config.approvalPolicy === bypassPolicy; + return [ + fullAccessToggle({ + isFullAccess: isBypass, + isDisabled, + onChange: (isSelected) => + onConfigChange({ approvalPolicy: isSelected ? bypassPolicy : "default" }), + }), + ]; +}); diff --git a/src/renderer/components/providers/index.ts b/src/renderer/components/providers/index.ts index 4c5701bf..b9072b22 100644 --- a/src/renderer/components/providers/index.ts +++ b/src/renderer/components/providers/index.ts @@ -15,6 +15,7 @@ export * from "./claude"; export * from "./copilot"; export * from "./codex"; export * from "./gemini"; +export * from "./grok"; export * from "./antigravity"; export * from "./cursor"; export * from "./opencode"; diff --git a/src/renderer/components/thread/ChatPane/ChatPane.tsx b/src/renderer/components/thread/ChatPane/ChatPane.tsx index af52fae0..140acd1d 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.tsx @@ -217,9 +217,13 @@ export function ChatPane(props: ChatPaneProps) { const showTailLoader = isLive || completedTurnCanRenderInTail; // The agent is not actually working while it waits for a user answer, so the // tail loader keeps rendering but its elapsed-time counter freezes for the - // duration of the wait and resumes once the thread flips back to working. - const isTurnPaused = - status === "needs_reply" || status === "needs_approval" || hasOpenRuntimeRequest; + // duration of the wait and resumes once the user submits a response. Anchor + // on `hasOpenRuntimeRequest` (cleared optimistically by the request panel) + // rather than `thread.status`, which only flips back to `working` after the + // supervisor's round-trip — for plan approvals the agent often opens a new + // request before that round-trip completes, leaving status stuck at + // `needs_approval` even though the user has already answered. + const isTurnPaused = hasOpenRuntimeRequest; const showEmptyHint = isEmpty && !isLive; // The tail loader displays the most recent completed turn's frozen elapsed // time when the thread is idle and no newer timeline row exists. Once an diff --git a/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx b/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx index f7b0bab5..cec7583e 100644 --- a/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx +++ b/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx @@ -47,8 +47,8 @@ interface ThreadRuntimeRequestPanelProps { * * Renders two flavors based on `request.requestType`: * - `tool_user_input` → vertical list of menu rows; click submits. - * - approval requests → primary action with chevron-dropdown for alternates, - * and negative options as ghost buttons. + * - approval requests (incl. `tool_call_approval`) → primary action with + * chevron-dropdown for alternates, and negative options as ghost buttons. * * Resolves through `resolveThreadServerRequest` with `method: "requestPermission"`, * matching the existing renderer<->supervisor contract. diff --git a/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts b/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts index 5609cae5..4e7772d8 100644 --- a/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts +++ b/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts @@ -30,6 +30,7 @@ const APPROVAL_REQUEST_TYPES = new Set([ "file_read_approval", "file_change_approval", "apply_patch_approval", + "tool_call_approval", ]); export function getApprovalDenyOption(request: OpenRuntimeRequest): UserInputOption | undefined { diff --git a/src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionRows.tsx b/src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionRows.tsx index 01f001e1..5506a7a2 100644 --- a/src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionRows.tsx +++ b/src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionRows.tsx @@ -62,6 +62,7 @@ export function QuestionRows(props: { isDisabled={isDisabled || selectedIds.length === 0} size="sm" variant="secondary" + className="text-white" onPress={() => onSubmit(selectedIds)} > Submit diff --git a/src/renderer/state/loginTerminalStore.ts b/src/renderer/state/loginTerminalStore.ts new file mode 100644 index 00000000..32b61c31 --- /dev/null +++ b/src/renderer/state/loginTerminalStore.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import type { ProjectLocation } from "@/shared/contracts"; + +/** + * One-shot login terminal session. Owned by `runAgentLoginCommand`, + * rendered by ``. The overlay slides over any + * existing overlay (e.g. Settings) so the user can complete a TUI auth + * flow without losing their place. + * + * `onForceClose` fires when the user dismisses the overlay before the + * command's own exit (X button / Escape). Callers use it to clear + * pending-state UI even when no exit code arrives. + */ +export interface LoginTerminalSession { + shellId: string; + label: string; + projectLocation: ProjectLocation; + onForceClose?: () => void; +} + +interface LoginTerminalState { + active: LoginTerminalSession | null; + open: (session: LoginTerminalSession) => void; + close: () => void; +} + +export const useLoginTerminalStore = create((set) => ({ + active: null, + open: (session) => set({ active: session }), + close: () => set((state) => (state.active === null ? {} : { active: null })), +})); diff --git a/src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx b/src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx new file mode 100644 index 00000000..6ebe3a43 --- /dev/null +++ b/src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@heroui/react"; +import { X } from "lucide-react"; +import { readBridge } from "@/renderer/bridge"; +import { + useLoginTerminalStore, + type LoginTerminalSession, +} from "@/renderer/state/loginTerminalStore"; +import { XTermSurface, type XTermSurfaceHandle } from "@/renderer/components/terminal/XTermSurface"; + +/** + * Transient terminal panel for one-shot login flows. Slides in from the right + * at z-60 so it can sit on top of the Settings overlay (z-50) — the user + * completes a TUI auth flow without losing their place in settings. + * + * Lifecycle: `runAgentLoginCommand` opens the store entry (and owns the shell + * + completion watcher). This component renders the xterm for that shell and + * forwards user-initiated close back through `onForceClose` so callers can + * reset their pending state. + */ +export function LoginTerminalOverlay() { + const active = useLoginTerminalStore((state) => state.active); + const [renderedSession, setRenderedSession] = useState(null); + const [visible, setVisible] = useState(false); + const xtermRef = useRef(null); + + useEffect(() => { + if (active) { + setRenderedSession(active); + // Double rAF: first frame commits the off-screen position to the DOM + // (browser paints it), second frame flips to translateX(0) so the + // transition has a starting point to animate from. A single rAF can be + // batched into the same paint as the initial mount and skip the slide. + let inner = 0; + const outer = requestAnimationFrame(() => { + inner = requestAnimationFrame(() => setVisible(true)); + }); + return () => { + cancelAnimationFrame(outer); + if (inner) cancelAnimationFrame(inner); + }; + } + setVisible(false); + }, [active]); + + useEffect(() => { + if (!visible || !renderedSession) return; + const timer = window.setTimeout(() => { + xtermRef.current?.focus(); + }, 60); + return () => window.clearTimeout(timer); + }, [visible, renderedSession]); + + // Close the underlying shell whenever we tear this session down — covers the + // natural-completion path (watcher → store.close → animate out → unmount) + // and the replace path (a new login session pushes onto the store before + // this one finishes). The X-button path also calls closeThread; that + // duplicate is safe (idempotent + caught). + useEffect(() => { + const id = renderedSession?.shellId; + if (!id) return; + return () => { + void readBridge() + .closeThread({ threadId: id }) + .catch(() => undefined); + }; + }, [renderedSession?.shellId]); + + const closeSession = () => { + if (!active) return; + const session = active; + void readBridge() + .closeThread({ threadId: session.shellId }) + .catch(() => undefined); + session.onForceClose?.(); + useLoginTerminalStore.getState().close(); + }; + + // Intentionally no global Escape handler — Esc must reach the embedded TUI + // (Gemini's auth picker, oauth-personal cancel, etc.). The X button is the + // only way to dismiss this overlay. + + function handleTransitionEnd() { + if (visible) return; + if (renderedSession) { + // Animation finished out — release the xterm. The shell, if still alive, + // is closed by closeSession; this only happens when the store cleared + // without going through closeSession (e.g. completion watcher). + setRenderedSession(null); + } + } + + if (!renderedSession) return null; + + return ( +
+
+
+
+

+ {renderedSession.label} login +

+

+ Complete the prompts in this terminal. Closes when finished. +

+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/renderer/views/MainView/parts/AppOverlays.tsx b/src/renderer/views/MainView/parts/AppOverlays.tsx index f1b6e85c..aff93b0a 100644 --- a/src/renderer/views/MainView/parts/AppOverlays.tsx +++ b/src/renderer/views/MainView/parts/AppOverlays.tsx @@ -31,6 +31,7 @@ import { performWorktreeRemoval } from "@/renderer/actions/worktreeActions"; import { WelcomeOverlay } from "@/renderer/views/WelcomeOverlay"; import { BrowserOverlay } from "@/renderer/views/MainView/parts/BrowserOverlay"; +import { LoginTerminalOverlay } from "@/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay"; export function AppOverlays() { const projects = useAppStore((s) => s.projects); @@ -168,6 +169,7 @@ export function AppOverlays() { ) : null} + ); } diff --git a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx index c4cba069..595b33f4 100644 --- a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx @@ -437,25 +437,26 @@ function supportsAcpLogoutStatus(status: AgentStatus, acpInstanceId: string | un function AcpAgentAuthEnvRow(props: { status: AgentStatus; - authMethod: AgentOwnedAuthMethod | AgentTerminalAuthMethod | undefined; + authMethods: ReadonlyArray; canLogout: boolean; authPending: boolean; pendingMessage: string | undefined; showEnvironmentLabel: boolean; - onLogin: () => void; + onLogin: (method: AgentOwnedAuthMethod | AgentTerminalAuthMethod) => void; onLogout: () => void; }) { - const { status, authMethod, showEnvironmentLabel } = props; + const { status, authMethods, showEnvironmentLabel } = props; + const hasAnyMethod = authMethods.length > 0; const isAuthenticated = status.authState === "authenticated"; const isMissing = - status.authState === "missing" || (status.authState === "unknown" && authMethod !== undefined); + status.authState === "missing" || (status.authState === "unknown" && hasAnyMethod); const env = envLabel(status); const envSuffix = showEnvironmentLabel && env ? ` ${env}` : ""; const envScope = env ? ` for ${env}` : ""; const envSubject = env || "Agent"; const canLogout = isAuthenticated && props.canLogout; - const canReLogin = isAuthenticated && !canLogout && authMethod !== undefined; - const canLogin = (isMissing || canReLogin) && authMethod !== undefined; + const canReLogin = isAuthenticated && !canLogout && hasAnyMethod; + const canLogin = (isMissing || canReLogin) && hasAnyMethod; const loginLabel = canReLogin ? "Re-login" : "Login"; const pendingLabel = canLogout ? "Logging out" : "Logging in"; const headerLabel = isMissing @@ -464,10 +465,14 @@ function AcpAgentAuthEnvRow(props: { ? "Signed in" : "Authentication"; const headerPrefix = env ? `${env} · ` : ""; + const hasMultipleMethods = authMethods.length > 1; + const singleMethod = !hasMultipleMethods ? authMethods[0] : undefined; const description = isMissing - ? authMethod - ? `Complete ${authMethod.name} sign-in${envScope}.` - : `${envSubject} needs authentication.` + ? hasMultipleMethods + ? `Choose how to sign in${envScope}.` + : singleMethod + ? `Complete ${singleMethod.name} sign-in${envScope}.` + : `${envSubject} needs authentication.` : isAuthenticated ? `${envSubject} credentials are configured.` : ""; @@ -501,13 +506,27 @@ function AcpAgentAuthEnvRow(props: { ) : ( <> - {canLogin ? ( + {canLogin && hasMultipleMethods + ? authMethods.map((method) => ( + + )) + : null} + {canLogin && !hasMultipleMethods && singleMethod ? ( @@ -1198,32 +1217,34 @@ export function SingleAgentSettings(props: { agentKind: string }) { ) : null} {installedStatuses.map((status) => { const envKey = statusEnvKey(status); - const agentMethod = - status.authMethods?.find(isAgentAuthMethod) ?? sharedAgentAuthMethod; + const agentMethods = + status.authMethods?.filter(isAgentAuthMethod) ?? + (sharedAgentAuthMethod ? [sharedAgentAuthMethod] : []); const terminalMethod = findTerminalAuthMethodForStatus(status) ?? sharedTerminalAuthMethod; - const method = agentMethod ?? terminalMethod; + const methods: Array = + agentMethods.length > 0 ? agentMethods : terminalMethod ? [terminalMethod] : []; const isAuthenticated = status.authState === "authenticated"; const needsInteractiveRow = isAuthenticated || status.authState === "missing" || - (status.authState === "unknown" && method !== undefined); + (status.authState === "unknown" && methods.length > 0); if (!needsInteractiveRow) return null; return ( { - if (agentMethod) { - authenticateAgent({ status, method: agentMethod }); + onLogin={(method) => { + if (isAgentAuthMethod(method)) { + authenticateAgent({ status, method }); return; } - runTerminalLogin(status, terminalMethod); + runTerminalLogin(status, method); }} onLogout={() => logoutAgent(status)} /> diff --git a/src/shared/contracts/runtimeEvent.ts b/src/shared/contracts/runtimeEvent.ts index 3169355b..0009d6db 100644 --- a/src/shared/contracts/runtimeEvent.ts +++ b/src/shared/contracts/runtimeEvent.ts @@ -34,6 +34,7 @@ export const canonicalRequestTypeSchema = z.enum([ "file_read_approval", "file_change_approval", "apply_patch_approval", + "tool_call_approval", "tool_user_input", "auth_refresh", ]); diff --git a/src/supervisor/agents/acp/canonicalMapping.test.ts b/src/supervisor/agents/acp/canonicalMapping.test.ts index 0b4a5ffd..77b852b0 100644 --- a/src/supervisor/agents/acp/canonicalMapping.test.ts +++ b/src/supervisor/agents/acp/canonicalMapping.test.ts @@ -1029,4 +1029,49 @@ describe("mapAcpPermissionRequest", () => { }, }); }); + + it("classifies generic tool-call approvals as tool_call_approval with structured details", () => { + const state = createAcpMapperState("t-perm-tool"); + + const event = mapAcpPermissionRequest( + { + sessionId: "s1", + toolCall: { + title: "browser__new_tab", + kind: "other", + rawInput: { + variant: "UseTool", + tool_name: "browser__new_tab", + tool_input: { url: "https://www.bing.com", activate: true }, + }, + }, + options: [ + { optionId: "always-allow", name: "always allow", kind: "allow_always" }, + { optionId: "allow-once", name: "allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "reject once", kind: "reject_once" }, + ], + } as Parameters[0], + state, + "acp-perm-tool-0", + ); + + expect(event).toEqual({ + type: "request.opened", + threadId: "t-perm-tool", + requestId: "acp-perm-tool-0", + requestType: "tool_call_approval", + payload: { + summary: "browser__new_tab", + details: { + toolName: "browser__new_tab", + input: { url: "https://www.bing.com", activate: true }, + }, + options: [ + { optionId: "always-allow", label: "always allow", description: undefined }, + { optionId: "allow-once", label: "allow once", description: undefined }, + { optionId: "reject-once", label: "reject once", description: undefined }, + ], + }, + }); + }); }); diff --git a/src/supervisor/agents/acp/canonicalMapping.ts b/src/supervisor/agents/acp/canonicalMapping.ts index ef50ea44..2e1ce38e 100644 --- a/src/supervisor/agents/acp/canonicalMapping.ts +++ b/src/supervisor/agents/acp/canonicalMapping.ts @@ -1065,7 +1065,9 @@ export function mapAcpPermissionRequest( const details = requestType === "command_execution_approval" && command ? buildCommandPermissionDetails(toolCall.rawInput, kind) - : toolCall.rawInput; + : requestType === "tool_call_approval" + ? buildToolCallPermissionDetails(toolCall.rawInput, title, kind) + : toolCall.rawInput; const options = req.options.map((opt) => ({ optionId: opt.optionId, label: opt.name, @@ -1100,6 +1102,23 @@ function buildCommandPermissionDetails( }; } +function buildToolCallPermissionDetails( + rawInput: unknown, + title: string | undefined, + kind: string | undefined, +): PermissionRequestDetails { + const toolName = readStringField(rawInput, "tool_name") ?? title ?? kind ?? "tool"; + const toolInput = + rawInput && typeof rawInput === "object" && "tool_input" in rawInput + ? (rawInput as { tool_input: unknown }).tool_input + : rawInput; + return { + toolName, + ...(title && title !== toolName ? { displayName: title } : {}), + input: toolInput, + }; +} + function stripCommandFromApprovalTitle(title: string | undefined, command: string): string { if (!title) return "Run command"; const colon = title.indexOf(":"); @@ -1146,5 +1165,5 @@ function classifyApprovalRequestType( } if (k === "edit" || /\b(edit|patch)\b/.test(t)) return "apply_patch_approval"; if (k === "write" || /\bwrite\b/.test(t)) return "file_change_approval"; - return "tool_user_input"; + return "tool_call_approval"; } diff --git a/src/supervisor/agents/acp/probe.ts b/src/supervisor/agents/acp/probe.ts index 67d01568..754295e4 100644 --- a/src/supervisor/agents/acp/probe.ts +++ b/src/supervisor/agents/acp/probe.ts @@ -60,6 +60,14 @@ export interface AcpProbeResult { authState?: AuthState; models?: Array<{ id: string; label: string; description?: string; tooltipDescription?: string }>; modelMetadata?: Record>; + /** + * Raw `_meta` collected during the probe handshake, merged across the + * `initialize`, `authenticate`, and `newSession` responses (later sources + * win on key conflicts). Provider-specific — Grok returns identity fields + * (`email`, `auth_mode`, `subscription_tier`) on its `authenticate` + * response. Adapters translate this into `AgentProviderMetadata`. + */ + acpMeta?: Record; efforts?: string[]; defaultEffort?: string; modelEfforts?: Record; @@ -337,6 +345,15 @@ export async function probeAcpCapabilities( timeoutMs?: number; label?: string; env?: Record; + /** + * Auth method IDs to call `authenticate` with (in order) after `initialize` + * but before `newSession`. Stops at the first one advertised by the agent. + * Used to retrieve identity metadata from agents that return it via the + * authenticate response (e.g. Grok returns email/plan in `_meta` there). + * Only safe for non-interactive flows like `cached_token` — never pass IDs + * that trigger browser OAuth. + */ + authenticateMethodIds?: readonly string[]; }, ): Promise { const timeoutMs = options?.timeoutMs ?? 15_000; @@ -401,6 +418,7 @@ export async function probeAcpCapabilities( } return Promise.resolve(); }, + extNotification: () => Promise.resolve(), }), stream, ); @@ -418,6 +436,9 @@ export async function probeAcpCapabilities( if (initResult.agentCapabilities?.auth?.logout !== undefined) { probeResult.authLogoutSupported = true; } + if (initResult._meta && typeof initResult._meta === "object") { + probeResult.acpMeta = initResult._meta as Record; + } // Non-spec compatibility fallback for agents that still expose // commands during initialize instead of session/update. @@ -426,6 +447,31 @@ export async function probeAcpCapabilities( latestSlashCommands = mapAcpSlashCommands(rawCommands); } + const preferredAuthMethodId = options?.authenticateMethodIds?.find((id) => + initResult.authMethods?.some((method) => method.id === id), + ); + if (preferredAuthMethodId) { + try { + const authResult = (await connection.authenticate({ + methodId: preferredAuthMethodId, + })) as { _meta?: unknown } | undefined; + const authMeta = authResult?._meta; + if (authMeta && typeof authMeta === "object") { + probeResult.acpMeta = { + ...(probeResult.acpMeta ?? {}), + ...(authMeta as Record), + }; + } + } catch (err) { + console.log( + "%s authenticate(%s) failed: %s", + tag, + preferredAuthMethodId, + err instanceof Error ? err.message : String(err), + ); + } + } + try { return await connection.newSession({ cwd: sessionCwd, mcpServers: [] }); } catch (err) { @@ -456,6 +502,13 @@ export async function probeAcpCapabilities( probeResult.sessionEstablished = true; probeResult.authState = "authenticated"; + const newSessionMeta = (result as { _meta?: unknown })._meta; + if (newSessionMeta && typeof newSessionMeta === "object") { + probeResult.acpMeta = { + ...(probeResult.acpMeta ?? {}), + ...(newSessionMeta as Record), + }; + } if (result.models?.availableModels?.length) { probeResult.models = mapAcpModels(result.models.availableModels); const modelMetadata = mapAcpModelMetadata(result.models.availableModels); @@ -590,6 +643,9 @@ export async function authenticateAcpAgent( sessionUpdate() { return Promise.resolve(); }, + extNotification() { + return Promise.resolve(); + }, }), ndJsonStream(toAgent, fromAgent), ); @@ -664,6 +720,9 @@ export async function logoutAcpAgent( sessionUpdate() { return Promise.resolve(); }, + extNotification() { + return Promise.resolve(); + }, }), ndJsonStream(toAgent, fromAgent), ); diff --git a/src/supervisor/agents/acp/session.test.ts b/src/supervisor/agents/acp/session.test.ts index 29e72171..2b192089 100644 --- a/src/supervisor/agents/acp/session.test.ts +++ b/src/supervisor/agents/acp/session.test.ts @@ -1136,6 +1136,71 @@ describe("ACP permission request handling", () => { expect(listener.onRuntimeEvent).not.toHaveBeenCalled(); }); + it("auto-approves bypassPermissions policy when no native ACP mode exists", async () => { + const { listener, session } = makeConfigSyncSession({ + availableModeIds: ["agent"], + currentConfig: { + model: "model-a", + effort: "low", + mode: "agent", + approvalPolicy: "bypassPermissions", + }, + }); + + const response = await invokePermission(session, [ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + { optionId: "always", name: "Allow always", kind: "allow_always" }, + ]); + + expect(response).toEqual({ outcome: { outcome: "selected", optionId: "always" } }); + expect(listener.onUpdate).not.toHaveBeenCalledWith({ + status: "needs_approval", + attention: "needs_approval", + }); + expect(listener.onRuntimeEvent).not.toHaveBeenCalled(); + }); + + it("auto-approves 'yolo' policy when no native ACP mode exists", async () => { + const { session } = makeConfigSyncSession({ + availableModeIds: ["agent"], + currentConfig: { + model: "model-a", + effort: "low", + mode: "agent", + approvalPolicy: "yolo", + }, + }); + + const response = await invokePermission(session, [ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + { optionId: "always", name: "Allow always", kind: "allow_always" }, + ]); + + expect(response).toEqual({ outcome: { outcome: "selected", optionId: "always" } }); + }); + + it("does not auto-approve non-bypass policies", async () => { + const { listener, session } = makeConfigSyncSession({ + availableModeIds: ["agent"], + currentConfig: { + model: "model-a", + effort: "low", + mode: "agent", + approvalPolicy: "default", + }, + }); + + void invokePermission(session, [ + { optionId: "always", name: "Allow always", kind: "allow_always" }, + ]); + await Promise.resolve(); + + expect(listener.onUpdate).toHaveBeenCalledWith({ + status: "needs_approval", + attention: "needs_approval", + }); + }); + it("does not auto-approve prompts when a native ACP permission mode exists", async () => { const { listener, session } = makeConfigSyncSession({ availableModeIds: ["agent", "yolo"], diff --git a/src/supervisor/agents/acp/session.ts b/src/supervisor/agents/acp/session.ts index 516f9f38..0ccd37ae 100644 --- a/src/supervisor/agents/acp/session.ts +++ b/src/supervisor/agents/acp/session.ts @@ -385,6 +385,14 @@ function completeAcpTerminal(record: AcpTerminalRecord, status: TerminalExitStat } } +function looksLikeAcpSessionNotification(params: unknown): params is SessionNotification { + if (!params || typeof params !== "object") return false; + const p = params as { sessionId?: unknown; update?: unknown }; + if (typeof p.sessionId !== "string") return false; + if (!p.update || typeof p.update !== "object") return false; + return typeof (p.update as { sessionUpdate?: unknown }).sessionUpdate === "string"; +} + function childExitStatus(code: number | null, signal: NodeJS.Signals | null): TerminalExitStatus { return { ...(typeof code === "number" ? { exitCode: code } : {}), @@ -510,7 +518,12 @@ export class AcpStructuredSession implements StructuredSessionHandle { private shouldAutoApproveSyntheticPermissionRequest(): boolean { const config = this.currentConfig; const policy = config?.approvalPolicy; - if (!config || config.mode === "plan" || policy !== "never") return false; + if (!config || config.mode === "plan" || !policy) return false; + // Bypass-style policy ids across adapters: legacy "never"/"yolo" and the + // adapter-agnostic "bypassPermissions" used by Claude, Grok, etc. When the + // agent has no native ACP mode for the requested policy we resolve the + // synthetic request ourselves instead of prompting the user. + if (policy !== "never" && policy !== "yolo" && policy !== "bypassPermissions") return false; return !hasNativeAcpPermissionMode(policy, this.availableModeIds); } @@ -778,6 +791,10 @@ export class AcpStructuredSession implements StructuredSessionHandle { session.handleKillTerminal(params); return {}; }, + extNotification(method: string, params: Record) { + session.handleExtNotification(method, params); + return Promise.resolve(); + }, }), stream, ); @@ -1494,6 +1511,28 @@ export class AcpStructuredSession implements StructuredSessionHandle { } } + /** + * Handle vendor-extension JSON-RPC notifications (methods outside the ACP + * spec). The SDK routes anything that isn't `session/update` or + * `session/elicitation_complete` here; without a handler the connection + * throws `methodNotFound` and logs every notification as an error. + * + * Grok's `_x.ai/session_notification` carries the same `{ sessionId, update }` + * shape as a standard `session/update`, just with extension-only + * `sessionUpdate` discriminators (`hook_execution`, etc.). Forward it to the + * normal handler — the canonical mapper falls through to its `default` arm + * on unrecognized discriminators, so unknown extensions are swallowed + * without polluting the chat stream. + */ + private handleExtNotification(method: string, params: Record): void { + if (looksLikeAcpSessionNotification(params)) { + console.log("[acp] forwarding extension notification to session/update:", method); + this.handleSessionUpdate(params as unknown as SessionNotification); + return; + } + console.log("[acp] ignoring extension notification:", method); + } + /** * Handle `session/update` notifications from the agent. * diff --git a/src/supervisor/agents/antigravity/index.ts b/src/supervisor/agents/antigravity/index.ts index 3caa3642..703b5ff0 100644 --- a/src/supervisor/agents/antigravity/index.ts +++ b/src/supervisor/agents/antigravity/index.ts @@ -1,8 +1,8 @@ import type { AgentCapability, PromptSegment, ProjectLocation } from "@/shared/contracts"; import { createKnownSessionRef, - createRecursiveDirWatcher, detectAgentInstall, + watchSessionPaths, type AgentAdapter, } from "../base"; import { buildAntigravityArgs } from "./argv"; @@ -18,7 +18,7 @@ import { readAntigravityConversationIds, readAntigravityLastConversationForCwd, readNewestAntigravityConversationId, - resolveAntigravityWatchPath, + resolveAntigravityWatchPaths, } from "./session"; import { detectAntigravityTerminalStatus } from "./terminal"; @@ -87,10 +87,11 @@ export function createAntigravityAdapter(): AgentAdapter { }, watchSessionRef(location, onChanged) { - const watchPath = resolveAntigravityWatchPath(location); - if (!watchPath) return undefined; - return createRecursiveDirWatcher( - watchPath, + const paths = resolveAntigravityWatchPaths(location); + if (paths.length === 0) return undefined; + return watchSessionPaths( + location, + paths, onChanged, `antigravity:${describeAntigravityLocation(location)}`, ); diff --git a/src/supervisor/agents/antigravity/session.test.ts b/src/supervisor/agents/antigravity/session.test.ts index 3abd936f..e8dbc447 100644 --- a/src/supervisor/agents/antigravity/session.test.ts +++ b/src/supervisor/agents/antigravity/session.test.ts @@ -55,9 +55,9 @@ describe("Antigravity session files", () => { const configDir = join(home, ".gemini", "antigravity-cli"); mkdirSync(join(configDir, "conversations"), { recursive: true }); - const { resolveAntigravityWatchPath } = await loadSessionModule(home); + const { resolveAntigravityWatchPaths } = await loadSessionModule(home); - expect(resolveAntigravityWatchPath(location)).toBe(configDir); + expect(resolveAntigravityWatchPaths(location)).toEqual([configDir, join(home, ".gemini")]); }); it("reads the cwd mapping and newest new conversation id", async () => { diff --git a/src/supervisor/agents/antigravity/session.ts b/src/supervisor/agents/antigravity/session.ts index 62ed3e37..36a66dcd 100644 --- a/src/supervisor/agents/antigravity/session.ts +++ b/src/supervisor/agents/antigravity/session.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import type { ProjectLocation } from "@/shared/contracts"; -import { resolveAgentHomeSubpath } from "../base"; +import { resolveAgentHomeSubpath, resolveWslHomeDirectory } from "../base"; const INVALID_SESSION_RE = /not\s+found|invalid\s+conversation|no\s+such\s+conversation/i; @@ -22,10 +23,6 @@ export function resolveAntigravityConfigDir(location: ProjectLocation): string | return resolveAgentHomeSubpath(location, ANTIGRAVITY_CONFIG_SUBPATH); } -function resolveAntigravityParentDir(location: ProjectLocation): string | undefined { - return resolveAgentHomeSubpath(location, ANTIGRAVITY_PARENT_SUBPATH); -} - export function antigravityConfigDirExists(location: ProjectLocation): boolean { const dir = resolveAntigravityConfigDir(location); return Boolean(dir && existsSync(dir)); @@ -91,14 +88,26 @@ export function locationCwd(location: ProjectLocation): string { return location.kind === "wsl" ? location.linuxPath : location.path; } -// Watch the config root because `agy` stores cwd -> conversation mappings in -// cache/last_conversations.json and conversation payloads under conversations/. -export function resolveAntigravityWatchPath(location: ProjectLocation): string | undefined { - const configDir = resolveAntigravityConfigDir(location); - if (configDir && existsSync(configDir)) return configDir; - const parentDir = resolveAntigravityParentDir(location); - if (parentDir && existsSync(parentDir)) return parentDir; - return undefined; +/** + * Absolute paths to watch for new/changed `agy` conversations. Native paths + * for windows/posix; Linux paths inside the distro for WSL (consumed by the + * in-distro bridge watch subscription, NOT UNC `\\wsl.localhost\…`). + * + * Watching the config root catches both cache/last_conversations.json + * writes (cwd → conversation map) and conversations/ writes (payloads). + */ +export function resolveAntigravityWatchPaths(location: ProjectLocation): string[] { + if (location.kind === "wsl") { + const home = resolveWslHomeDirectory(location.distro); + if (!home) return []; + return [`${home}/${ANTIGRAVITY_CONFIG_SUBPATH}`, `${home}/${ANTIGRAVITY_PARENT_SUBPATH}`]; + } + const home = homedir(); + const paths = [ + join(home, ...ANTIGRAVITY_CONFIG_SUBPATH.split("/")), + join(home, ANTIGRAVITY_PARENT_SUBPATH), + ]; + return paths.filter((p) => existsSync(p)); } export function describeAntigravityLocation(location: ProjectLocation): string { diff --git a/src/supervisor/agents/base/index.ts b/src/supervisor/agents/base/index.ts index d61732e5..e30daafe 100644 --- a/src/supervisor/agents/base/index.ts +++ b/src/supervisor/agents/base/index.ts @@ -2,7 +2,12 @@ import { existsSync, readFileSync, watch as fsWatch } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { toWslUncPath } from "@/shared/wsl"; -import type { AgentStatus, AuthState, ProjectLocation } from "@/shared/contracts"; +import type { + AgentProviderMetadata, + AgentStatus, + AuthState, + ProjectLocation, +} from "@/shared/contracts"; import { primeAgentBinaryPath, resolveAgentBinaryPath } from "../binaryResolver"; import { batchWslCommandsAsync, @@ -98,6 +103,7 @@ export * from "./terminalHints"; export * from "./promptSession"; export * from "./processRuntime"; export * from "./shellBasics"; +export * from "./sessionFs"; export function buildWindowsCmdCommand(cwd: string, command: string, args: string[]): CommandSpec { return { command: "C:\\Windows\\System32\\cmd.exe", @@ -582,6 +588,7 @@ export async function detectAgentInstall( let probedAuthMethods: AgentStatus["authMethods"]; let probedAuthLogoutSupported: boolean | undefined; let probedAuthState: AuthState | undefined; + let probedProviderMetadata: AgentProviderMetadata | undefined; if (executablePath) { const probeCtx: DetectProbeCtx = { location, executablePath, version }; const [capabilityPartial, nextStatusProbeResult] = await Promise.all([ @@ -593,6 +600,7 @@ export async function detectAgentInstall( authMethods: probeAuthMethods, authLogoutSupported: probeAuthLogoutSupported, authState: probeAuthStateValue, + providerMetadata: probeProviderMetadata, ...capabilityRest } = capabilityPartial; capabilities = { ...capabilities, ...capabilityRest }; @@ -605,6 +613,9 @@ export async function detectAgentInstall( if (probeAuthStateValue !== undefined) { probedAuthState = probeAuthStateValue; } + if (probeProviderMetadata) { + probedProviderMetadata = probeProviderMetadata; + } } statusProbeResult = nextStatusProbeResult; } @@ -636,6 +647,8 @@ export async function detectAgentInstall( } } + const providerMetadata = statusProbeResult?.providerMetadata ?? probedProviderMetadata; + return { kind: spec.kind, label: spec.label, @@ -645,9 +658,7 @@ export async function detectAgentInstall( ...(version ? { version } : {}), ...(spec.update ? { update: spec.update } : {}), authState, - ...(statusProbeResult?.providerMetadata - ? { providerMetadata: statusProbeResult.providerMetadata } - : {}), + ...(providerMetadata ? { providerMetadata } : {}), ...(probedAuthMethods ? { authMethods: probedAuthMethods } : {}), ...(probedAuthLogoutSupported ? { authLogoutSupported: true } : {}), capabilities, diff --git a/src/supervisor/agents/base/sessionFs.ts b/src/supervisor/agents/base/sessionFs.ts new file mode 100644 index 00000000..c1f88482 --- /dev/null +++ b/src/supervisor/agents/base/sessionFs.ts @@ -0,0 +1,341 @@ +import { existsSync, readFileSync, readdirSync, statSync, watch as fsWatch } from "node:fs"; +import { join } from "node:path"; +import type { ProjectLocation } from "@/shared/contracts"; +import { toWslUncPath } from "@/shared/wsl"; +import type { WslBridgeClient, WslLocation } from "../../wsl/bridge/client"; +import { resolveWslHomeDirectory } from "./processRuntime"; + +/** + * Shared filesystem helpers for agent session discovery. Routes WSL reads + * and watches through the long-lived in-distro `WslBridgeServer` (HTTP + * loopback, ~5ms/call) instead of spawning `wsl.exe` per call (~50–100ms). + * + * Falls back to native `node:fs` for `posix` / `windows` locations so + * adapters branch on platform once at the helper boundary, not at every + * call site. + * + * Session files (e.g. `~/.codex/sessions/...`, `~/.grok/sessions/...`) + * live in `$HOME`, not the project root. The bridge endpoint enforces + * `path inside projectRoot`, so we synthesize a `WslLocation` whose + * `linuxPath` is the distro `$HOME` for these calls. The safety check + * still holds — reads cannot escape `$HOME`. + */ + +let bridgeClient: WslBridgeClient | undefined; + +export function setSessionFsBridgeClient(client: WslBridgeClient | undefined): void { + bridgeClient = client; +} + +function bridgeLocationForHome(distro: string): WslLocation | undefined { + const home = resolveWslHomeDirectory(distro); + if (!home) return undefined; + return { kind: "wsl", distro, linuxPath: home, uncPath: toWslUncPath(distro, home) }; +} + +export interface SessionDirEntry { + name: string; + type: "file" | "directory" | "symlink" | "other"; +} + +export async function listSessionDir( + location: ProjectLocation, + absolutePath: string, +): Promise { + if (location.kind !== "wsl") { + try { + return readdirSync(absolutePath, { withFileTypes: true }).map((d) => ({ + name: d.name, + type: d.isFile() + ? "file" + : d.isDirectory() + ? "directory" + : d.isSymbolicLink() + ? "symlink" + : "other", + })); + } catch { + return undefined; + } + } + const client = bridgeClient; + if (!client) return undefined; + const synLoc = bridgeLocationForHome(location.distro); + if (!synLoc) return undefined; + try { + const result = await client.readdir(synLoc, absolutePath); + return result.entries.map((e) => ({ name: e.name, type: e.type })); + } catch { + return undefined; + } +} + +export interface SessionStat { + exists: boolean; + mtimeMs?: number; + isDirectory?: boolean; + isFile?: boolean; +} + +export async function statSessionPaths( + location: ProjectLocation, + paths: string[], +): Promise> { + if (paths.length === 0) return new Map(); + if (location.kind !== "wsl") { + const map = new Map(); + for (const p of paths) { + try { + const st = statSync(p); + map.set(p, { + exists: true, + mtimeMs: st.mtimeMs, + isDirectory: st.isDirectory(), + isFile: st.isFile(), + }); + } catch { + map.set(p, { exists: false }); + } + } + return map; + } + const client = bridgeClient; + const synLoc = bridgeLocationForHome(location.distro); + if (!client || !synLoc) { + return new Map(paths.map((p) => [p, { exists: false }])); + } + try { + const result = await client.stat(synLoc, paths); + const map = new Map(); + for (const s of result.stats) { + map.set( + s.path, + s.exists + ? { + exists: true, + ...(typeof s.mtimeMs === "number" ? { mtimeMs: s.mtimeMs } : {}), + ...(typeof s.isDirectory === "boolean" ? { isDirectory: s.isDirectory } : {}), + ...(typeof s.isFile === "boolean" ? { isFile: s.isFile } : {}), + } + : { exists: false }, + ); + } + return map; + } catch { + return new Map(paths.map((p) => [p, { exists: false }])); + } +} + +export async function readSessionFileText( + location: ProjectLocation, + absolutePath: string, + maxBytes = 0, +): Promise { + if (location.kind !== "wsl") { + try { + const raw = readFileSync(absolutePath); + if (maxBytes > 0 && raw.length > maxBytes) return undefined; + return raw.toString("utf8"); + } catch { + return undefined; + } + } + const client = bridgeClient; + const synLoc = bridgeLocationForHome(location.distro); + if (!client || !synLoc) return undefined; + try { + const result = await client.readFile(synLoc, absolutePath, { maxBytes }); + if ("tooLarge" in result && result.tooLarge) return undefined; + return Buffer.from(result.contentBase64, "base64").toString("utf8"); + } catch { + return undefined; + } +} + +export interface FindSessionFilesOptions { + /** Absolute root directory to walk. */ + root: string; + /** Filter on basename. Default accepts every file. */ + acceptFile?: (name: string) => boolean; + /** Directory basenames to skip. */ + ignore?: string[]; + /** Hard cap on returned entries. Default 10 000. */ + maxEntries?: number; + /** Populate `mtimeMs` on each returned entry. Costs one extra batched stat. */ + includeMtime?: boolean; +} + +export interface FoundSessionFile { + path: string; + name: string; + mtimeMs?: number; +} + +export async function findSessionFiles( + location: ProjectLocation, + opts: FindSessionFilesOptions, +): Promise { + const acceptFile = opts.acceptFile ?? (() => true); + const maxEntries = opts.maxEntries ?? 10_000; + const ignore = opts.ignore ?? []; + + if (location.kind !== "wsl") { + const out: FoundSessionFile[] = []; + const walk = (dir: string): void => { + if (out.length >= maxEntries) return; + let entries: import("node:fs").Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const d of entries) { + if (ignore.includes(d.name)) continue; + if (out.length >= maxEntries) return; + const full = join(dir, d.name); + if (d.isDirectory()) { + walk(full); + continue; + } + if (d.isFile() && acceptFile(d.name)) { + if (opts.includeMtime) { + try { + out.push({ path: full, name: d.name, mtimeMs: statSync(full).mtimeMs }); + } catch { + out.push({ path: full, name: d.name }); + } + } else { + out.push({ path: full, name: d.name }); + } + } + } + }; + walk(opts.root); + return out; + } + + const client = bridgeClient; + const synLoc = bridgeLocationForHome(location.distro); + if (!client || !synLoc) return []; + try { + const result = await client.find(synLoc, { + root: opts.root, + maxEntries, + ignore, + }); + const matches = result.entries + .filter((e) => e.type === "file" && acceptFile(e.name)) + .map((e) => ({ + path: e.path.startsWith("/") ? e.path : `${opts.root.replace(/\/+$/, "")}/${e.path}`, + name: e.name, + })); + if (!opts.includeMtime || matches.length === 0) return matches; + const stats = await client.stat( + synLoc, + matches.map((m) => m.path), + ); + const byPath = new Map(stats.stats.map((s) => [s.path, s] as const)); + return matches.map((m) => { + const s = byPath.get(m.path); + return typeof s?.mtimeMs === "number" ? { ...m, mtimeMs: s.mtimeMs } : m; + }); + } catch { + return []; + } +} + +/** + * Watch a set of absolute paths for changes. Returns a sync teardown that is + * safe to call before the underlying async subscribe resolves. + */ +export function watchSessionPaths( + location: ProjectLocation, + paths: string[], + onChanged: () => void, + label: string, +): () => void { + if (paths.length === 0) return () => undefined; + if (location.kind !== "wsl") { + const watchers: Array<() => void> = []; + for (const p of paths) { + if (!existsSync(p)) continue; + try { + const w = fsWatch(p, { recursive: true }, () => onChanged()); + w.on("error", () => { + try { + w.close(); + } catch { + /* ignore */ + } + }); + watchers.push(() => { + try { + w.close(); + } catch { + /* ignore */ + } + }); + } catch (error) { + console.log( + "[%s] session watcher unavailable for %s: %s", + label, + p, + error instanceof Error ? error.message : String(error), + ); + } + } + if (watchers.length > 0) { + console.log("[%s] session watcher active (%d native path(s))", label, watchers.length); + } + return () => { + for (const close of watchers) close(); + }; + } + + const client = bridgeClient; + const synLoc = bridgeLocationForHome(location.distro); + if (!client || !synLoc) return () => undefined; + + let disposed = false; + let unsubscribe: (() => Promise) | undefined; + // The bridge's watchSubscribe rolls back ALL paths if ANY fails to start, + // so pre-filter to paths that exist on disk. The home root itself is the + // safest broad watch when no specific subpath is present yet. + (async () => { + const stats = await client.stat(synLoc, paths); + const existing = stats.stats.filter((s) => s.exists).map((s) => s.path); + if (existing.length === 0 || disposed) return; + try { + const sub = await client.watch( + synLoc, + { paths: existing.map((p) => ({ path: p, scope: "unknown" as const })) }, + () => { + if (!disposed) onChanged(); + }, + ); + if (disposed) { + void sub.unsubscribe(); + return; + } + unsubscribe = sub.unsubscribe; + console.log("[%s] session watcher active (%d bridge path(s))", label, existing.length); + } catch (err) { + console.log( + "[%s] bridge session watcher failed: %s", + label, + err instanceof Error ? err.message : String(err), + ); + } + })().catch((err) => { + console.log( + "[%s] bridge session watcher setup failed: %s", + label, + err instanceof Error ? err.message : String(err), + ); + }); + + return () => { + disposed = true; + if (unsubscribe) void unsubscribe(); + }; +} diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index 12edb4c2..431e856d 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -163,6 +163,7 @@ export type CapabilitiesProbeResult = Partial & { authMethods?: AgentAuthMethod[]; authLogoutSupported?: boolean; authState?: AuthState; + providerMetadata?: AgentProviderMetadata; }; export interface DetectionSpec { diff --git a/src/supervisor/agents/claude/plugin/plugin.json b/src/supervisor/agents/claude/plugin/plugin.json index 356a639f..d0994f41 100644 --- a/src/supervisor/agents/claude/plugin/plugin.json +++ b/src/supervisor/agents/claude/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lightcode-status", - "version": "1.1.3", + "version": "1.1.4", "description": "Forward Claude Code lifecycle events to Lightcode for sidebar status detection.", "author": "Lightcode", "license": "Apache-2.0" diff --git a/src/supervisor/agents/codex/index.ts b/src/supervisor/agents/codex/index.ts index 06c5807e..a8b91ea3 100644 --- a/src/supervisor/agents/codex/index.ts +++ b/src/supervisor/agents/codex/index.ts @@ -5,9 +5,9 @@ import { brailleSpinnerOscTitleHint, buildAgentLogoutCommand, createKnownSessionRef, - createRecursiveDirWatcher, detectAgentInstall, getOscNotificationText, + watchSessionPaths, type AgentAdapter, type CreateStructuredSessionInput, type TerminalStatusHint, @@ -32,10 +32,12 @@ import { import { describeCodexLocation, isInteractiveCodexRollout, - readCodexRolloutMetaForLocation, + readCodexRolloutMetaForLocationAsync, readCodexRolloutsForLocation, + readCodexRolloutsForLocationAsync, readCodexSessionIndexForLocation, - resolveCodexSessionsWatchPath, + readCodexSessionIndexForLocationAsync, + resolveCodexSessionWatchPaths, } from "./session"; import type { CodexRolloutMeta } from "./sessionFiles"; import { detectCodexReadyForInitialPrompt } from "./terminal"; @@ -209,24 +211,27 @@ export function createCodexAdapter(): AgentAdapter { }, initialSessionRefDiscoveryDelayMs: 1000, watchSessionRef(location, onChanged) { - const watchPath = resolveCodexSessionsWatchPath(location); - if (!watchPath) return undefined; - return createRecursiveDirWatcher( - watchPath, + const paths = resolveCodexSessionWatchPaths(location); + if (paths.length === 0) return undefined; + return watchSessionPaths( + location, + paths, onChanged, `codex:${describeCodexLocation(location)}`, ); }, async discoverSessionRef(location) { try { - const sessions = readCodexSessionIndexForLocation(location); - const rollouts = readCodexRolloutsForLocation(location); + const [sessions, rollouts] = await Promise.all([ + readCodexSessionIndexForLocationAsync(location), + readCodexRolloutsForLocationAsync(location), + ]); const newRollouts = rollouts .filter((rollout) => !preSpawnRolloutIds.has(rollout.id)) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); let next: CodexRolloutMeta | undefined; for (const candidate of newRollouts) { - const meta = readCodexRolloutMetaForLocation(location, candidate); + const meta = await readCodexRolloutMetaForLocationAsync(location, candidate); if (meta && isInteractiveCodexRollout(meta, location)) { next = meta; break; diff --git a/src/supervisor/agents/codex/plugin/plugin.json b/src/supervisor/agents/codex/plugin/plugin.json index 41b342be..8fc194b2 100644 --- a/src/supervisor/agents/codex/plugin/plugin.json +++ b/src/supervisor/agents/codex/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lightcode-status", - "version": "1.1.3", + "version": "1.1.4", "description": "Forward Codex CLI lifecycle hooks to Lightcode for sidebar status detection.", "author": "Lightcode", "license": "Apache-2.0" diff --git a/src/supervisor/agents/codex/session.ts b/src/supervisor/agents/codex/session.ts index a2f61027..c23d9e98 100644 --- a/src/supervisor/agents/codex/session.ts +++ b/src/supervisor/agents/codex/session.ts @@ -3,11 +3,11 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { ProjectLocation } from "@/shared/contracts"; import { resolveLightcodePaths } from "@/shared/lightcodePaths"; -import { toWslUncPath } from "@/shared/wsl"; import { + findSessionFiles, quotePosixShellArg, + readSessionFileText, readWslCommandOutput, - resolveAgentHomeSubpath, resolveWslHomeDirectory, resolveWslShellPath, } from "../base"; @@ -95,6 +95,31 @@ export function readCodexSessionIndexForLocation(location: ProjectLocation) { return dedupeSessionIndex([...sessions, ...parseCodexSessionIndex(privateRaw)]); } +/** + * Async variant routed through the in-distro bridge on WSL (~10ms via HTTP + * loopback) instead of `wsl.exe` cold start (~50–100ms). Used by the async + * `discoverSessionRef` hot path; the sync version above remains for + * `buildLaunchArgv` where the call site itself is sync. + */ +export async function readCodexSessionIndexForLocationAsync( + location: ProjectLocation, +): Promise> { + if (location.kind !== "wsl") { + return readCodexSessionIndexForLocation(location); + } + const home = resolveWslHomeDirectory(location.distro); + const privateHome = wslPrivateCodexHome(location.distro); + const paths = [ + home ? `${home}/.codex/session_index.jsonl` : undefined, + privateHome ? `${privateHome}/session_index.jsonl` : undefined, + ].filter((p): p is string => Boolean(p)); + const reads = await Promise.all(paths.map((p) => readSessionFileText(location, p))); + const parts = reads.filter((r): r is string => typeof r === "string" && r.length > 0); + if (parts.length === 0) return []; + const merged = parts.flatMap((raw) => parseCodexSessionIndex(raw)); + return dedupeSessionIndex(merged); +} + export function isInteractiveCodexRollout( rollout: CodexRolloutMeta, location: ProjectLocation, @@ -234,16 +259,73 @@ export function readCodexRolloutMetaForLocation( } } -export function resolveCodexSessionsWatchPath(location: ProjectLocation): string | undefined { +export async function readCodexRolloutMetaForLocationAsync( + location: ProjectLocation, + rollout: CodexRolloutMeta, +): Promise { + if (location.kind !== "wsl") { + return readCodexRolloutMetaForLocation(location, rollout); + } + const text = await readSessionFileText(location, rollout.path); + if (!text) return rollout; + const firstLine = text.split(/\r?\n/g)[0] ?? ""; + return parseCodexRolloutMeta(rollout.path, firstLine, rollout.updatedAt) ?? rollout; +} + +export async function readCodexRolloutsForLocationAsync( + location: ProjectLocation, +): Promise { + if (location.kind !== "wsl") { + return readCodexRolloutsForLocation(location); + } + const home = resolveWslHomeDirectory(location.distro); + const privateHome = wslPrivateCodexHome(location.distro); + const roots = [ + home ? `${home}/.codex/sessions` : undefined, + privateHome ? `${privateHome}/sessions` : undefined, + ].filter((r): r is string => Boolean(r)); + + const accept = (name: string): boolean => name.startsWith("rollout-") && name.endsWith(".jsonl"); + const found = ( + await Promise.all( + roots.map((root) => + findSessionFiles(location, { root, acceptFile: accept, includeMtime: true }), + ), + ) + ).flat(); + + return dedupeRollouts( + found.flatMap((f) => { + const id = parseCodexRolloutIdFromPath(f.path); + if (!id) return []; + const meta: CodexRolloutMeta = { + id, + path: f.path, + ...(typeof f.mtimeMs === "number" ? { updatedAt: Math.round(f.mtimeMs) } : {}), + }; + return [meta]; + }), + ); +} + +/** + * Returns absolute paths to watch for new/changed Codex rollouts. Native + * paths for windows/posix; Linux paths inside the distro for WSL (consumed + * by the in-distro bridge watch subscription, NOT UNC `\\wsl.localhost\…`). + */ +export function resolveCodexSessionWatchPaths(location: ProjectLocation): string[] { if (location.kind === "wsl") { + const home = resolveWslHomeDirectory(location.distro); const privateHome = wslPrivateCodexHome(location.distro); - if (privateHome) { - return toWslUncPath(location.distro, `${privateHome}/sessions`); - } + return [ + home ? `${home}/.codex/sessions` : undefined, + privateHome ? `${privateHome}/sessions` : undefined, + ].filter((p): p is string => Boolean(p)); } + const paths: string[] = []; + const publicSessions = join(homedir(), ".codex", "sessions"); + if (existsSync(publicSessions)) paths.push(publicSessions); const privateSessions = join(nativePrivateCodexHome(), "sessions"); - if (location.kind !== "wsl" && existsSync(nativePrivateCodexHome())) { - return privateSessions; - } - return resolveAgentHomeSubpath(location, ".codex/sessions"); + if (existsSync(privateSessions)) paths.push(privateSessions); + return paths; } diff --git a/src/supervisor/agents/copilot/argv.ts b/src/supervisor/agents/copilot/argv.ts index 1080d129..bfed3135 100644 --- a/src/supervisor/agents/copilot/argv.ts +++ b/src/supervisor/agents/copilot/argv.ts @@ -7,7 +7,13 @@ export function buildCopilotArgs( sessionId: string, _launchOptions?: { suppressResumeConfigOverrides?: boolean }, ): string[] { - const args = [`--resume=${sessionId}`, "--allow-all-paths"]; + // `--session-id` both pins the UUID for a new session (matching the one the + // ACP probe minted via newSession) and resumes an existing session/task. + // `--resume=` only matches an existing session, task, or name — Copilot + // CLI 1.0.52 rejects the ACP-minted UUID before any conversation turns have + // been recorded against it, producing "Error: No session, task, or name + // matched ''" and exiting non-zero. `--session-id` covers both cases. + const args = [`--session-id=${sessionId}`, "--allow-all-paths"]; // Copilot's TUI only reflects the selected model/effort when the resume // command also carries those flags, even if ACP already applied them. diff --git a/src/supervisor/agents/copilot/plugin/plugin.json b/src/supervisor/agents/copilot/plugin/plugin.json index 0738f38e..a1bd177d 100644 --- a/src/supervisor/agents/copilot/plugin/plugin.json +++ b/src/supervisor/agents/copilot/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lightcode-status-copilot", - "version": "1.0.2", + "version": "1.0.3", "description": "GitHub Copilot CLI lifecycle hook forwarder for Lightcode thread status", "author": "Lightcode", "license": "Apache-2.0" diff --git a/src/supervisor/agents/cursor/plugin/install.test.ts b/src/supervisor/agents/cursor/plugin/install.test.ts index 49cfcd0f..4f73a17b 100644 --- a/src/supervisor/agents/cursor/plugin/install.test.ts +++ b/src/supervisor/agents/cursor/plugin/install.test.ts @@ -96,7 +96,7 @@ describe("installCursorPlugin", () => { expect(existsSync(result.paths.globalHooksPath)).toBe(true); const installed = await isCursorPluginInstalledForTest(baseDir, globalCursorDirOverride); - expect(installed).toMatchObject({ installed: true, version: "1.0.3" }); + expect(installed).toMatchObject({ installed: true, version: "1.0.4" }); const doc = JSON.parse(readFileSync(result.paths.globalHooksPath, "utf8")) as { version: number; diff --git a/src/supervisor/agents/cursor/plugin/plugin.json b/src/supervisor/agents/cursor/plugin/plugin.json index 400bb94a..7f8d07fa 100644 --- a/src/supervisor/agents/cursor/plugin/plugin.json +++ b/src/supervisor/agents/cursor/plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "lightcode-status-cursor", - "version": "1.0.3", + "version": "1.0.4", "description": "Cursor CLI lifecycle hook forwarder for Lightcode thread status" } diff --git a/src/supervisor/agents/gemini/detection.test.ts b/src/supervisor/agents/gemini/detection.test.ts index 5db76f93..ac1ce12f 100644 --- a/src/supervisor/agents/gemini/detection.test.ts +++ b/src/supervisor/agents/gemini/detection.test.ts @@ -42,12 +42,16 @@ describe("geminiDetectionSpec", () => { it("uses the native project location and resolved executable for non-WSL probes", async () => { const location: ProjectLocation = { kind: "posix", path: "/Users/demo/project" }; + // Even when the ACP probe fails, Gemini surfaces a terminal auth method + // so the Login button stays in the settings UI. await expect( geminiDetectionSpec.capabilitiesProbe?.({ location, executablePath: "/Users/demo/.local/bin/gemini", }), - ).resolves.toBeUndefined(); + ).resolves.toEqual({ + authMethods: [{ id: "gemini-terminal-login", name: "Login", type: "terminal" }], + }); expect(buildAgentCommandMock).toHaveBeenCalledWith(location, "/Users/demo/.local/bin/gemini", [ "--acp", diff --git a/src/supervisor/agents/gemini/detection.ts b/src/supervisor/agents/gemini/detection.ts index f6c6cda3..aa39be31 100644 --- a/src/supervisor/agents/gemini/detection.ts +++ b/src/supervisor/agents/gemini/detection.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { AgentCapability } from "@/shared/contracts"; +import type { AgentCapability, AgentTerminalAuthMethod } from "@/shared/contracts"; import { compactAgentProviderMetadata } from "@/shared/contracts"; -import { dedupeAcpAuthMethods, probeAcpCapabilities } from "../acp"; +import { probeAcpCapabilities } from "../acp"; import { batchWslCommandsAsync, buildAgentCommand, @@ -129,7 +129,7 @@ export const geminiDetectionSpec: DetectionSpec = { kind: "gemini", label: "Gemini", binary: "gemini", - loginCommand: "gemini auth login", + loginCommand: "gemini /auth", capabilities: defaultGeminiCapabilities, update: { npm: "@google/gemini-cli", @@ -154,15 +154,27 @@ export const geminiDetectionSpec: DetectionSpec = { ? `gemini:wsl:${ctx.location.distro}` : `gemini:${ctx.location.kind}`, }); - if (!probeResult) return undefined; + // Gemini's ACP authMethods are mostly non-functional over the protocol — + // only `oauth-personal` works via `authenticate()`; the API-key/Vertex/ + // Gateway methods require env vars set before agent spawn and fail + // silently when invoked through ACP. Synthesize one terminal method that + // opens Gemini's own TUI auth picker (via the loginCommand), which + // handles every flow correctly. Returned unconditionally so the settings + // Login button stays present even when the ACP probe transiently fails + // (e.g. right after the user force-closes an in-flight /auth session). + const terminalAuthMethod: AgentTerminalAuthMethod = { + id: "gemini-terminal-login", + name: "Login", + type: "terminal", + }; + if (!probeResult) { + return { authMethods: [terminalAuthMethod] }; + } const modelTokens = new Map(); for (const model of probeResult.models ?? []) { const tokens = geminiModelContextTokens(model.id); if (tokens !== undefined) modelTokens.set(model.id, tokens); } - const dedupedAuthMethods = probeResult.authMethods?.length - ? dedupeAcpAuthMethods(probeResult.authMethods) - : undefined; return { ...(probeResult.models?.length ? { models: probeResult.models } : {}), ...(probeResult.efforts?.length ? { efforts: probeResult.efforts } : {}), @@ -173,7 +185,7 @@ export const geminiDetectionSpec: DetectionSpec = { : {}), ...(probeResult.slashCommands?.length ? { slashCommands: probeResult.slashCommands } : {}), ...buildContextSizeCapabilities(modelTokens), - ...(dedupedAuthMethods?.length ? { authMethods: dedupedAuthMethods } : {}), + authMethods: [terminalAuthMethod], ...(probeResult.authLogoutSupported ? { authLogoutSupported: true } : {}), ...(probeResult.authState ? { authState: probeResult.authState } : {}), }; diff --git a/src/supervisor/agents/gemini/gemini.test.ts b/src/supervisor/agents/gemini/gemini.test.ts index b82ce333..af25a1a3 100644 --- a/src/supervisor/agents/gemini/gemini.test.ts +++ b/src/supervisor/agents/gemini/gemini.test.ts @@ -292,7 +292,7 @@ describe("createGeminiAdapter hook plugin support", () => { expect(adapter.capabilities.presentationModes).toEqual(["terminal", "gui"]); expect(adapter.createStructuredSession).toBeTypeOf("function"); expect(adapter.pluginId).toBe("lightcode-status@gemini"); - expect(adapter.pluginVersion).toBe("1.2.2"); + expect(adapter.pluginVersion).toBe("1.2.3"); expect(adapter.minProtocolVersion).toBe(1); const extras = await adapter.pluginLaunchExtras?.({ diff --git a/src/supervisor/agents/gemini/plugin/install.test.ts b/src/supervisor/agents/gemini/plugin/install.test.ts index a0af2e5a..2433f038 100644 --- a/src/supervisor/agents/gemini/plugin/install.test.ts +++ b/src/supervisor/agents/gemini/plugin/install.test.ts @@ -80,7 +80,7 @@ describe("installGeminiPlugin", () => { expect(existsSync(result.paths.settingsPath)).toBe(true); expect(isGeminiPluginInstalled({ envKind: "posix", baseDir })).toMatchObject({ installed: true, - version: "1.2.2", + version: "1.2.3", }); const settings = JSON.parse(readFileSync(result.paths.settingsPath, "utf8")) as { diff --git a/src/supervisor/agents/gemini/plugin/plugin.json b/src/supervisor/agents/gemini/plugin/plugin.json index dea7b9c7..f6c38faa 100644 --- a/src/supervisor/agents/gemini/plugin/plugin.json +++ b/src/supervisor/agents/gemini/plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "lightcode-status-gemini", - "version": "1.2.2", + "version": "1.2.3", "description": "Gemini CLI lifecycle hook forwarder for Lightcode thread status" } diff --git a/src/supervisor/agents/grok/argv.test.ts b/src/supervisor/agents/grok/argv.test.ts new file mode 100644 index 00000000..b6ca91ca --- /dev/null +++ b/src/supervisor/agents/grok/argv.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { buildGrokArgs, buildGrokAcpArgs } from "./argv"; + +describe("buildGrokArgs (TUI/PTY)", () => { + it("emits nothing for a bare default config", () => { + expect(buildGrokArgs({ mode: "agent" } as any, "", undefined)).toEqual([]); + }); + + it("passes -r when the session id is known", () => { + expect(buildGrokArgs({ mode: "agent" } as any, "", "abc-123")).toEqual(["-r", "abc-123"]); + }); + + it("never emits -c, --no-plan, --permission-mode, or --effort", () => { + const cases: Array> = [ + { mode: "agent" }, + { mode: "plan" }, + { mode: "agent", approvalPolicy: "bypassPermissions" }, + { mode: "agent", approvalPolicy: "default", effort: "high" }, + ]; + for (const c of cases) { + const args = buildGrokArgs(c as any, "", undefined); + expect(args).not.toContain("-c"); + expect(args).not.toContain("--no-plan"); + expect(args).not.toContain("--permission-mode"); + expect(args).not.toContain("--effort"); + expect(args).not.toContain("--reasoning-effort"); + } + }); + + it("adds --always-approve when approval policy bypasses permissions", () => { + expect( + buildGrokArgs({ mode: "agent", approvalPolicy: "bypassPermissions" } as any, "", undefined), + ).toEqual(["--always-approve"]); + }); + + it("treats legacy 'never' and 'yolo' policies as bypass", () => { + for (const policy of ["never", "yolo"]) { + expect( + buildGrokArgs({ mode: "agent", approvalPolicy: policy } as any, "", undefined), + ).toContain("--always-approve"); + } + }); + + it("does not pass --always-approve for non-bypass policies", () => { + expect( + buildGrokArgs({ mode: "agent", approvalPolicy: "default" } as any, "", undefined), + ).not.toContain("--always-approve"); + }); + + it("passes -m when set", () => { + expect(buildGrokArgs({ mode: "agent", model: "grok-build" } as any, "", undefined)).toEqual([ + "-m", + "grok-build", + ]); + }); +}); + +describe("buildGrokAcpArgs (`grok agent stdio` prefix)", () => { + it("emits nothing for a bare default config", () => { + expect(buildGrokAcpArgs({} as any)).toEqual([]); + }); + + it("never emits --permission-mode, --no-plan, --effort, or --reasoning-effort", () => { + const args = buildGrokAcpArgs({ + mode: "plan", + approvalPolicy: "bypassPermissions", + effort: "high", + } as any); + expect(args).not.toContain("--permission-mode"); + expect(args).not.toContain("--no-plan"); + expect(args).not.toContain("--effort"); + expect(args).not.toContain("--reasoning-effort"); + }); + + it("adds --always-approve when approval policy bypasses permissions", () => { + expect(buildGrokAcpArgs({ approvalPolicy: "bypassPermissions" } as any)).toEqual([ + "--always-approve", + ]); + }); + + it("passes -m when set", () => { + expect(buildGrokAcpArgs({ model: "grok-build" } as any)).toEqual(["-m", "grok-build"]); + }); +}); diff --git a/src/supervisor/agents/grok/argv.ts b/src/supervisor/agents/grok/argv.ts new file mode 100644 index 00000000..1bbf365d --- /dev/null +++ b/src/supervisor/agents/grok/argv.ts @@ -0,0 +1,89 @@ +import type { ThreadConfig } from "@/shared/contracts"; + +/** + * Flag references — verified against `grok --help`, `grok agent --help`, and + * the Grok docs on grok 0.1.218: + * https://docs.x.ai/build/cli/headless-scripting + * https://docs.x.ai/build/modes-and-commands + * + * Constraints we encode here: + * • `--permission-mode ` is documented as headless-only — the TUI + * silently ignores it and `grok agent stdio` accepts it without effect + * (verified live: a session created with `--permission-mode plan` + * reports "normal mode" when asked). We don't pass it. The only + * approval control Grok honors at launch is `--always-approve` + * (alias `--yolo`). + * • `--no-plan` is a hard restriction — passing it disables plan tooling + * entirely. Lightcode never sets it; plan mode is entered when the + * model calls `enter_plan_mode` and the user approves + * (`~/.grok/docs/user-guide/19-plan-mode.md`). + * • `--effort` / `--reasoning-effort` are not surfaced in the Grok + * composer (the CLI flag is headless-only and ACP doesn't advertise + * effort either), so we don't emit them. + * • Grok ACP (`session/new`) does not advertise `modes` / `configOptions` + * for permission modes, so even on ACP we drive bypass via the CLI + * flag rather than `setSessionMode`. + */ + +function isBypassApproval(config: ThreadConfig): boolean { + switch (config.approvalPolicy) { + case "bypassPermissions": + return true; + // Tolerate legacy thread configs persisted before approvalPolicies were + // pruned to what Grok actually honors. + case "never": + case "yolo": + return true; + default: + return false; + } +} + +/** + * Argv for `grok` (TUI / PTY). + * + * Resume semantics (per `grok --help`): + * `-r, --resume []` Resume by ID, or the most recent if omitted. + * + * We pass `-r ` when we already know the session ID (typically minted via + * ACP just before launch). We never use `-c, --continue` — by user request, + * we standardise on `-r`. Bare `-r` is also skipped because Grok exits 1 when + * no prior session exists for the cwd. + */ +export function buildGrokArgs( + config: ThreadConfig, + _prompt: string, + resumeSessionId?: string, +): string[] { + const args: string[] = []; + + if (resumeSessionId) { + args.push("-r", resumeSessionId); + } + + if (config.model) { + args.push("-m", config.model); + } + + if (isBypassApproval(config)) { + args.push("--always-approve"); + } + + return args; +} + +/** + * Argv prefix for `grok [FLAGS] agent stdio` (ACP / GUI tab and mint helper). + */ +export function buildGrokAcpArgs(config: ThreadConfig): string[] { + const args: string[] = []; + + if (config.model) { + args.push("-m", config.model); + } + if (isBypassApproval(config)) { + args.push("--always-approve"); + } + + return args; +} diff --git a/src/supervisor/agents/grok/detection.ts b/src/supervisor/agents/grok/detection.ts new file mode 100644 index 00000000..5cec7887 --- /dev/null +++ b/src/supervisor/agents/grok/detection.ts @@ -0,0 +1,176 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + compactAgentProviderMetadata, + type AgentCapability, + type AgentProviderMetadata, + type ProjectLocation, +} from "@/shared/contracts"; +import { dedupeAcpAuthMethods, probeAcpCapabilities } from "../acp"; +import { + batchWslCommandsAsync, + buildAgentCommand, + envVarAuthProbe, + type CapabilitiesProbeResult, + type DetectionSpec, +} from "../base"; +import { buildContextSizeCapabilities } from "../contextWindowLabel"; +import { getAgentProbeCwd, resolveProbeSpawnCwd } from "../probeCwd"; + +// Approval policies surfaced to Lightcode. Grok only honors `--always-approve` +// (bypass) at launch — `--permission-mode ` is headless-only and is +// silently ignored by both the TUI and `grok agent stdio`. We therefore +// expose a single Default ↔ Bypass Approvals toggle in the composer. +const GROK_APPROVAL_POLICIES = [ + { id: "default", label: "Default" }, + { id: "bypassPermissions", label: "Bypass Approvals" }, +] as const; + +// Plan mode and effort are intentionally omitted from the composer surface: +// • Plan mode cannot be force-activated at launch on either Grok surface — +// `--permission-mode plan` is silently ignored. The model has to call +// `enter_plan_mode` itself (`~/.grok/docs/user-guide/19-plan-mode.md`), +// so a Plan/Work toggle would falsely imply we drive it. +// • Effort selection (`--effort` / `--reasoning-effort`) is headless-only +// for the CLI and not advertised by ACP either, so surfacing it would +// be misleading. +export const grokDefaultCapabilities: AgentCapability = { + models: [], + efforts: [], + modelEfforts: {}, + modes: ["agent"], + approvalPolicies: [...GROK_APPROVAL_POLICIES], + sandboxModes: [], + supportsResume: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal", "gui"], + defaultApprovalPolicy: "default", + bypassPermissions: { approvalPolicy: "bypassPermissions" }, + settingDefs: [], +}; + +export function buildGrokCommand(location: ProjectLocation, args: string[], wslExecPath?: string) { + return buildAgentCommand(location, "grok", args, wslExecPath); +} + +async function probeCapabilities( + location: ProjectLocation, + executablePath?: string, +): Promise { + const spec = buildGrokCommand(location, ["agent", "stdio"], executablePath); + const sessionCwd = getAgentProbeCwd(location); + const processCwd = resolveProbeSpawnCwd(location, spec.cwd); + const probe = await probeAcpCapabilities(spec.command, spec.args, sessionCwd, { + ...(processCwd ? { processCwd } : {}), + timeoutMs: 20_000, // grok may take a moment on first init + label: location.kind === "wsl" ? `grok:wsl:${location.distro}` : `grok:${location.kind}`, + // Grok returns identity (email, auth_mode, subscription_tier) in the + // `authenticate` response's `_meta`. `cached_token` is the non-interactive + // method written by `grok login` to `~/.grok/auth.json`, so it's safe to + // call during detection without triggering a browser flow. + authenticateMethodIds: ["cached_token"], + }); + + // Extract context window from model _meta (grok reports totalContextTokens) + let contextCaps: Pick = {}; + if (probe?.modelMetadata) { + const meta = probe.modelMetadata["grok-build"] ?? Object.values(probe.modelMetadata)[0]; + const tokens = (meta as { totalContextTokens?: unknown })?.totalContextTokens; + if (typeof tokens === "number" && tokens > 0) { + contextCaps = buildContextSizeCapabilities(new Map([["grok-build", tokens]])); + } + } + + const dedupedAuth = probe?.authMethods?.length + ? dedupeAcpAuthMethods(probe.authMethods) + : undefined; + + const providerMetadata = buildGrokProviderMetadata(probe?.acpMeta); + + return { + ...grokDefaultCapabilities, + ...(probe?.models?.length ? { models: probe.models } : {}), + ...(probe?.efforts?.length ? { efforts: probe.efforts } : {}), + ...(probe?.defaultEffort ? { defaultEffort: probe.defaultEffort } : {}), + ...(probe?.modelEfforts ? { modelEfforts: probe.modelEfforts } : {}), + ...(probe?.modes?.length ? { modes: probe.modes } : {}), + ...(probe?.approvalPolicies?.length ? { approvalPolicies: probe.approvalPolicies } : {}), + ...(probe?.slashCommands?.length ? { slashCommands: probe.slashCommands } : {}), + ...contextCaps, + ...(dedupedAuth?.length ? { authMethods: dedupedAuth } : {}), + // Grok always supports `grok logout` when the binary is present (CLI + ACP path). + authLogoutSupported: true, + ...(probe?.authState ? { authState: probe.authState } : {}), + ...(providerMetadata ? { providerMetadata } : {}), + }; +} + +/** + * Grok's ACP `authenticate` response (with the `cached_token` method) returns + * identity fields in `_meta` — `email`, `auth_mode`, `subscription_tier`. See + * https://docs.x.ai/build/cli/headless-scripting#acp. Translate them into the + * shared `AgentProviderMetadata` shape so the settings UI shows the signed-in + * email / plan instead of a generic "Signed in" line. + */ +export function buildGrokProviderMetadata( + meta: Record | undefined, +): AgentProviderMetadata | undefined { + if (!meta) return undefined; + const pick = (key: string): string | undefined => { + const value = meta[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + }; + const authMode = pick("auth_mode"); + return compactAgentProviderMetadata({ + ...(pick("email") ? { authenticatedAs: pick("email") } : {}), + ...(pick("subscription_tier") ? { plan: pick("subscription_tier") } : {}), + ...(authMode ? { authMethod: formatGrokAuthMode(authMode) } : {}), + }); +} + +function formatGrokAuthMode(mode: string): string { + if (mode.toLowerCase() === "oidc") return "OIDC"; + return mode; +} + +/** + * Grok stores auth in ~/.grok/auth.json (or the dir presence after login). + */ +async function grokConfigDirAuthProbe( + ctx: Parameters>[0], +): Promise<"authenticated" | "unknown"> { + const check = (home: string) => { + if (existsSync(join(home, ".grok", "auth.json"))) return "authenticated"; + if (existsSync(join(home, ".grok"))) return "authenticated"; + return "unknown"; + }; + if (ctx.location.kind !== "wsl") { + return check(homedir()); + } + const [r] = await batchWslCommandsAsync(ctx.location.distro, [ + "test -f ~/.grok/auth.json && echo yes || test -d ~/.grok && echo yes || echo no", + ]); + return r?.ok && r.stdout.trim() === "yes" ? "authenticated" : "unknown"; +} + +export const grokDetectionSpec: DetectionSpec = { + kind: "grok", + label: "Grok Build", + binary: "grok", + loginCommand: "grok login", + capabilities: grokDefaultCapabilities, + update: { + builtIn: { binary: "grok", args: ["update"] }, + }, + // Auth detection prefers XAI_API_KEY / GROK_API_KEY (for "xai.api_key" ACP method) + // then falls back to ~/.grok/auth.json (populated by `grok login` → "cached_token"). + // See https://docs.x.ai/build/enterprise#authentication + authProbes: [envVarAuthProbe(["GROK_API_KEY", "XAI_API_KEY"]), grokConfigDirAuthProbe], + async capabilitiesProbe(ctx) { + if (!ctx.executablePath) return undefined; + return probeCapabilities(ctx.location, ctx.executablePath); + }, +}; diff --git a/src/supervisor/agents/grok/grok.test.ts b/src/supervisor/agents/grok/grok.test.ts new file mode 100644 index 00000000..5ecce5c2 --- /dev/null +++ b/src/supervisor/agents/grok/grok.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { OscNotification, OscTitle } from "@/shared/osc"; +import { createGrokAdapter } from "./index"; + +function oscTitle(text: string, code: 0 | 1 | 2 = 0): OscTitle { + return { code, text }; +} + +function oscNotify(body: string, code: 9 | 99 | 777 = 9): OscNotification { + return { code, title: "", body, payload: undefined }; +} + +// Observed live from `grok 0.1.218` PTY capture (idle → user prompt → response): +// OSC 0 "grok" (idle, frequent) +// OSC 0 "⠴ - Waiting - grok" (working, braille frames ⠴ / ⠦) +// OSC 9 "4;0;0" (iTerm2 progress: clear → idle) +// No OSC 777 / 99 / 133 / 633 / 1337 emitted in the same run. +describe("createGrokAdapter handleOscTitle", () => { + const adapter = createGrokAdapter(); + + it("maps Grok's '⠴/⠦ - Waiting - grok' braille spinner to working", () => { + for (const glyph of ["⠴", "⠦"]) { + expect(adapter.handleOscTitle?.(oscTitle(`${glyph} - Waiting - grok`))).toEqual({ + status: "working", + attention: "working", + corroborated: true, + }); + } + }); + + it("accepts any braille glyph in the U+2800–U+28FF range", () => { + for (const glyph of ["⠀", "⠁", "⣾", "⣿"]) { + expect(adapter.handleOscTitle?.(oscTitle(`${glyph} task`))?.status).toBe("working"); + } + }); + + it("returns null for Grok's idle title (plain 'grok')", () => { + expect(adapter.handleOscTitle?.(oscTitle("grok"))).toBeNull(); + }); + + it("returns null when the braille glyph is not at the start of the title", () => { + expect(adapter.handleOscTitle?.(oscTitle("grok ⠴"))).toBeNull(); + }); +}); + +describe("createGrokAdapter handleOscNotification (iTerm2 OSC 9;4 progress)", () => { + const adapter = createGrokAdapter(); + + it("maps state 0 (remove progress) to idle — Grok's observed turn-end signal", () => { + for (const body of ["4;0", "4;0;", "4;0;0"]) { + expect(adapter.handleOscNotification?.(oscNotify(body))).toEqual({ + status: "idle", + attention: "none", + corroborated: true, + }); + } + }); + + it("maps state 1 / 3 to working", () => { + expect(adapter.handleOscNotification?.(oscNotify("4;1;42"))?.status).toBe("working"); + expect(adapter.handleOscNotification?.(oscNotify("4;3;0"))?.status).toBe("working"); + }); + + it("ignores OSC 9 bodies outside the 9;4 progress sub-protocol", () => { + expect(adapter.handleOscNotification?.(oscNotify("Hello from some other agent"))).toBeNull(); + expect(adapter.handleOscNotification?.(oscNotify(""))).toBeNull(); + }); + + it("ignores OSC 777 / OSC 99 — Grok only emits iTerm2 OSC 9", () => { + expect(adapter.handleOscNotification?.(oscNotify("4;0", 777))).toBeNull(); + expect(adapter.handleOscNotification?.(oscNotify("4;3;0", 99))).toBeNull(); + }); +}); + +describe("createGrokAdapter OSC plumbing", () => { + it("keeps OSC parsing active alongside the L1 hook plugin", () => { + const adapter = createGrokAdapter(); + expect(adapter.oscHintsDeferToHookPlugin).toBeUndefined(); + }); +}); + +describe("createGrokAdapter L1 hook plugin support", () => { + it("declares lightcode-status@grok with protocol version 1", () => { + const adapter = createGrokAdapter(); + expect(adapter.pluginId).toBe("lightcode-status@grok"); + expect(adapter.minProtocolVersion).toBe(1); + expect(typeof adapter.pluginVersion).toBe("string"); + expect(adapter.pluginVersion?.length ?? 0).toBeGreaterThan(0); + }); + + it("returns no extra args/env from pluginLaunchExtras (auto-loaded global hooks)", async () => { + const adapter = createGrokAdapter(); + const extras = await adapter.pluginLaunchExtras?.({ envKind: "posix" }); + expect(extras).toEqual({}); + expect(extras?.args).toBeUndefined(); + expect(extras?.env).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/grok/index.ts b/src/supervisor/agents/grok/index.ts new file mode 100644 index 00000000..870a088e --- /dev/null +++ b/src/supervisor/agents/grok/index.ts @@ -0,0 +1,192 @@ +import type { PromptSegment } from "@/shared/contracts"; +import { createAcpStructuredSession } from "../acp"; +import { + brailleSpinnerOscTitleHint, + createKnownSessionRef, + detectAgentInstall, + detectProbeLocation, + iterm2ProgressOscHint, + type AgentAdapter, + type AgentEnvContext, + type CreateStructuredSessionInput, +} from "../base"; +import { resolveAgentBinaryPath } from "../binaryResolver"; +import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase"; +import { buildGrokAcpArgs, buildGrokArgs } from "./argv"; +import { buildGrokCommand, grokDefaultCapabilities, grokDetectionSpec } from "./detection"; +import { + installGrokPlugin, + isGrokPluginInstalled, + readBundledGrokPluginVersion, +} from "./plugin/install"; +import { + makeGrokDiscoverSessionRef, + makeGrokWatchSessionRef, + mintGrokSessionIdViaAcpSync, + snapshotGrokPreSpawnSessions, +} from "./sessionFiles"; + +const GROK_PLUGIN_VERSION = readBundledGrokPluginVersion(); + +warnIfPluginManifestMissing("grok", GROK_PLUGIN_VERSION); + +// Grok Build provider implementation. +// Docs: https://docs.x.ai/build/overview +// ACP: https://docs.x.ai/build/cli/headless-scripting#acp +// Modes/permissions: https://docs.x.ai/build/modes-and-commands +// Enterprise auth: https://docs.x.ai/build/enterprise + +export function createGrokAdapter(): AgentAdapter { + let capabilities = grokDefaultCapabilities; + + return { + kind: "grok", + label: "Grok Build", + binary: "grok", + ...(grokDetectionSpec.update ? { update: grokDetectionSpec.update } : {}), + get capabilities() { + return capabilities; + }, + // WSL OAuth/login flows open a browser; neutralise so PTY does not hang. + spawnEnv: { wsl: { BROWSER: "/bin/true" } }, + + // Grok emits a braille-spinner prefix in OSC 0 titles while a turn is + // active ("⠴ - Waiting - grok") and clears back to plain "grok" when idle. + // It also speaks the iTerm2 OSC 9;4 progress sub-protocol (state 0 = idle, + // 1/3 = working). Kept as a redundant signal alongside the L1 hook plugin + // — do not set oscHintsDeferToHookPlugin. + handleOscNotification: iterm2ProgressOscHint, + handleOscTitle: brailleSpinnerOscTitleHint, + + pluginId: "lightcode-status@grok", + pluginVersion: GROK_PLUGIN_VERSION, + minProtocolVersion: 1, + + async isPluginSupported() { + return true; + }, + async isPluginInstalled(ctx) { + return isGrokPluginInstalled(ctx); + }, + async installPlugin(ctx) { + const node = await resolveInstallNodePath(ctx); + if (!node.ok) return node; + const result = installGrokPlugin(ctx, { resolvedNodePath: node.nodePath }); + if (!result.ok) return result; + return { ok: true, version: result.version }; + }, + // No `pluginLaunchExtras` env/args needed — Grok auto-loads + // `~/.grok/hooks/lightcode-status.json` written at install time, and + // `LIGHTCODE_HOOK_*` env is injected by the coordinator. + async pluginLaunchExtras() { + return {}; + }, + + async detectInstall(ctx) { + const status = await detectAgentInstall(ctx, grokDetectionSpec); + capabilities = status.capabilities; + return status; + }, + + buildLaunchArgv(location, config, prompt, sessionRef, _launchOptions) { + const cwd = location.kind === "wsl" ? location.linuxPath : location.path; + // Snapshot existing session dirs so discoverSessionRef can identify the + // new UUID Grok creates if minting fails and the PTY ends up creating + // its own session. + snapshotGrokPreSpawnSessions(location, cwd); + + // Resolve a session ID before spawning the PTY: prefer one we already + // know (resume), otherwise mint one via a short-lived `grok agent stdio` + // handshake so the TUI loads directly into the chat composer. Without + // an ID Grok renders its welcome menu in cwds with prior sessions — + // that breaks `isReadyForInitialPrompt` and the initial prompt never + // gets delivered. + let resumeId = sessionRef?.providerSessionId; + if (!resumeId) { + resumeId = mintGrokSessionIdViaAcpSync(location, 2600, buildGrokAcpArgs(config)); + } + + const args = buildGrokArgs(config, prompt, resumeId); + // Returning the minted/resumed id as sessionRef lets the runtime skip + // post-spawn discovery on the happy path (mirrors gemini/cursor). + // discoverSessionRef stays wired up as the fallback for mint failures + // in fresh cwds where Grok creates its own session. + return { + binary: "grok", + args, + ...(resumeId ? { sessionRef: createKnownSessionRef(resumeId) } : {}), + }; + }, + + buildResumeArgv(_location, config, prompt, sessionRef) { + const args = buildGrokArgs(config, prompt, sessionRef?.providerSessionId); + return { binary: "grok", args }; + }, + + async createStructuredSession(input: CreateStructuredSessionInput) { + const acpArgs = buildGrokAcpArgs(input.config); + const command = buildGrokCommand( + input.projectLocation, + [...acpArgs, "agent", "stdio"], + resolveAgentBinaryPath(input.projectLocation, "grok"), + ); + return createAcpStructuredSession(command, input); + }, + + async buildAcpAuthCommand(ctx?: AgentEnvContext) { + const location = detectProbeLocation(ctx); + return buildGrokCommand( + location, + ["agent", "stdio"], + resolveAgentBinaryPath(location, "grok"), + ); + }, + + async buildAcpLogoutCommand(ctx?: AgentEnvContext) { + const location = detectProbeLocation(ctx); + return buildGrokCommand(location, ["logout"], resolveAgentBinaryPath(location, "grok")); + }, + + createInitialSessionRef() { + return undefined; + }, + + // After a PTY Grok launch we discover the real native session UUID that + // Grok wrote to ~/.grok/sessions/// (the directory name is the + // stable ID). Subsequent CLI resumes then use precise `-r ` and the + // Chat (ACP) + Terminal tabs share the exact same Grok session. + initialSessionRefDiscoveryDelayMs: 1200, + discoverSessionRef: makeGrokDiscoverSessionRef(), + watchSessionRef: makeGrokWatchSessionRef(), + + buildDirectInput(prompt) { + // Grok TUI may batch pasted input (especially on fresh/resumed sessions); + // space out the submit slightly so the composer treats it as typed text + enter. + return [prompt, "@wait:120", "\r"]; + }, + + isReadyForInitialPrompt(text) { + const t = text.toLowerCase(); + if (t.includes("grok build")) return true; + if (/type @|mention files|\/ commands/i.test(text)) return true; + return false; + }, + + formatPromptSegments(segments: PromptSegment[]) { + // Grok supports @path style file references in prompts (similar to others). + const attachments = segments.filter((s) => s.kind === "attachment"); + const rest = segments.filter((s) => s.kind !== "attachment"); + const attachmentLines = attachments.map((s) => `@${s.path}`).join(" "); + const restStr = rest.map((s) => (s.kind === "file" ? `@${s.path}` : s.content)).join(""); + return attachmentLines ? `${restStr}\n\n${attachmentLines} ` : restStr; + }, + + // Grok's TUI has no CLI flag for an initial interactive prompt — the only + // headless prompt path is `grok -p` (non-interactive). Defer the prompt to + // the PTY: the runtime queues it as `pendingTerminalPrompt` and types it + // via `buildDirectInput` once `isReadyForInitialPrompt` fires. + shouldDeferPromptToTerminal() { + return true; + }, + }; +} diff --git a/src/supervisor/agents/grok/plugin/forward.mjs b/src/supervisor/agents/grok/plugin/forward.mjs new file mode 100644 index 00000000..dab34b8d --- /dev/null +++ b/src/supervisor/agents/grok/plugin/forward.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Grok CLI lifecycle hook forwarder for Lightcode. + * + * The Grok TUI auto-loads global hooks from `~/.grok/hooks/*.json` on every + * session start (always trusted, no per-project prompt). The user-global + * `lightcode-status.json` written at install time points each event's command + * at this script via the staged `lightcode-hook.{sh,cmd,ps1}` wrapper (native) + * or an absolute node path (WSL). + * + * Hook stdin carries the Grok event envelope: `{ hookEventName, sessionId, + * cwd, workspaceRoot, toolName?, toolInput?, ... }`. We never deny tool calls + * — passive instrumentation only. + * + * Generic plumbing (manifest read, env-var POST, retry, debug) lives in the + * shared `lightcode-hook-runtime.mjs` sibling. NOTE: the intent map below + * mirrors `intentMap.ts` — keep both in sync. + */ + +import { + copyStringExtra, + readPluginVersionFromManifest, + runForwarder, +} from "./lightcode-hook-runtime.mjs"; + +const PLUGIN_VERSION = readPluginVersionFromManifest(import.meta.url); + +function normalizeEventName(eventName, payload) { + const fromPayload = + typeof payload?.hookEventName === "string" ? payload.hookEventName : undefined; + return fromPayload ?? eventName; +} + +function notificationNeedsApproval(payload) { + const notificationType = + `${payload?.notificationType ?? payload?.notification_type ?? payload?.type ?? ""}`.toLowerCase(); + const message = `${payload?.message ?? ""}`.toLowerCase(); + return ( + notificationType.includes("permission") || + notificationType.includes("approval") || + message.includes("permission") || + message.includes("approval") + ); +} + +function intentFor(eventName, payload) { + const name = normalizeEventName(eventName, payload).toLowerCase(); + switch (name) { + case "sessionstart": + case "session_start": + return "session.started"; + case "userpromptsubmit": + case "user_prompt_submit": + return "session.turn_started"; + case "stop": + return "session.turn_finished"; + case "notification": + return notificationNeedsApproval(payload) ? "session.needs_approval" : undefined; + default: + return undefined; + } +} + +function buildExtra(eventName, payload) { + const extra = { agentNativeEvent: eventName }; + if (payload && typeof payload === "object") { + copyStringExtra(extra, payload, "hookEventName", "hookEventName"); + copyStringExtra(extra, payload, "toolName", "tool"); + copyStringExtra(extra, payload, "notificationType", "notificationType"); + copyStringExtra(extra, payload, "notification_type", "notificationType"); + copyStringExtra(extra, payload, "type", "notificationType"); + copyStringExtra(extra, payload, "message", "message"); + copyStringExtra(extra, payload, "source", "source"); + if (typeof payload.timestamp === "string") { + extra.agentTimestamp = payload.timestamp; + } + } + return extra; +} + +function pickSessionId(payload) { + if (!payload || typeof payload !== "object") return undefined; + for (const key of ["sessionId", "session_id"]) { + const v = payload[key]; + if (typeof v === "string" && v.length > 0) return v; + } + return undefined; +} + +await runForwarder({ + agentKind: "grok", + pluginVersion: PLUGIN_VERSION, + intentFor, + buildExtra, + pickSessionId, + debugLabel: "grok", +}); diff --git a/src/supervisor/agents/grok/plugin/install.test.ts b/src/supervisor/agents/grok/plugin/install.test.ts new file mode 100644 index 00000000..b1fd9b34 --- /dev/null +++ b/src/supervisor/agents/grok/plugin/install.test.ts @@ -0,0 +1,181 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { grokIntentFor } from "./intentMap"; +import { + GLOBAL_HOOK_DIR_NAME, + GLOBAL_HOOK_FILENAME, + GROK_HOOK_EVENTS, + getGrokPluginPaths, + installGrokPlugin, + isGrokPluginInstalled, + renderGrokHookConfig, +} from "./install"; + +describe("grokIntentFor", () => { + it("maps Grok's registered lifecycle events to Lightcode intents", () => { + expect(grokIntentFor("SessionStart", undefined)).toBe("session.started"); + expect(grokIntentFor("UserPromptSubmit", undefined)).toBe("session.turn_started"); + expect(grokIntentFor("Stop", undefined)).toBe("session.turn_finished"); + }); + + it("accepts the snake_case form from the stdin payload's hookEventName", () => { + expect(grokIntentFor("", { hookEventName: "session_start" })).toBe("session.started"); + expect(grokIntentFor("", { hookEventName: "user_prompt_submit" })).toBe("session.turn_started"); + expect(grokIntentFor("", { hookEventName: "stop" })).toBe("session.turn_finished"); + }); + + it("maps Notification → needs_approval only when the payload signals approval", () => { + expect(grokIntentFor("Notification", { type: "permissionRequest" })).toBe( + "session.needs_approval", + ); + expect(grokIntentFor("Notification", { message: "Approval required" })).toBe( + "session.needs_approval", + ); + expect(grokIntentFor("Notification", { type: "info", message: "hello" })).toBeUndefined(); + }); + + it("returns undefined for unknown events", () => { + expect(grokIntentFor("PreToolUse", undefined)).toBeUndefined(); + expect(grokIntentFor("Unknown", undefined)).toBeUndefined(); + expect(grokIntentFor("", undefined)).toBeUndefined(); + }); +}); + +describe("renderGrokHookConfig", () => { + it("emits Grok hook schema with all registered events", () => { + const config = renderGrokHookConfig({ command: "'/wrapper.sh'" }); + expect(Object.keys(config.hooks).sort()).toEqual([...GROK_HOOK_EVENTS].sort()); + }); + + it("appends the event name to the command for each event", () => { + const config = renderGrokHookConfig({ command: "'/wrapper.sh'" }); + expect(config.hooks.SessionStart?.[0]?.hooks[0]?.command).toBe("'/wrapper.sh' SessionStart"); + expect(config.hooks.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe( + "'/wrapper.sh' UserPromptSubmit", + ); + expect(config.hooks.Stop?.[0]?.hooks[0]?.command).toBe("'/wrapper.sh' Stop"); + expect(config.hooks.Notification?.[0]?.hooks[0]?.command).toBe("'/wrapper.sh' Notification"); + }); + + it("sets a fixed timeout per hook entry", () => { + const config = renderGrokHookConfig({ command: "'/wrapper.sh'" }); + for (const event of GROK_HOOK_EVENTS) { + expect(config.hooks[event]?.[0]?.hooks[0]?.timeout).toBe(5); + } + }); + + it("uses the Claude-style command shape Grok expects", () => { + const config = renderGrokHookConfig({ command: "'/wrapper.sh'" }); + const entry = config.hooks.SessionStart?.[0]?.hooks[0]; + expect(entry?.type).toBe("command"); + expect(typeof entry?.command).toBe("string"); + }); +}); + +describe("installGrokPlugin (native, global hook write)", () => { + function makeNativeCtx() { + const baseDir = mkdtempSync(join(tmpdir(), "lightcode-grok-stage-")); + const grokDir = mkdtempSync(join(tmpdir(), "lightcode-grok-home-")); + const envKind = process.platform === "win32" ? ("windows" as const) : ("posix" as const); + return { baseDir, grokDir, ctx: { envKind, baseDir } }; + } + + it("writes ~/.grok/hooks/lightcode-status.json at install time", () => { + const { grokDir, ctx } = makeNativeCtx(); + const result = installGrokPlugin(ctx, { globalGrokDirOverride: grokDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const expected = join(grokDir, GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME); + expect(result.paths.globalHookFilePath).toBe(expected); + expect(existsSync(expected)).toBe(true); + const written = JSON.parse(readFileSync(expected, "utf8")) as { + hooks: Record< + string, + Array<{ hooks: Array<{ type: string; command: string; timeout: number }> }> + >; + }; + expect(Object.keys(written.hooks).sort()).toEqual([...GROK_HOOK_EVENTS].sort()); + const stop = written.hooks.Stop?.[0]?.hooks?.[0]; + expect(stop?.type).toBe("command"); + expect(stop?.timeout).toBe(5); + expect(stop?.command.endsWith(" Stop")).toBe(true); + }); + + it("is idempotent — re-install with identical inputs does not bump mtime", async () => { + const { grokDir, ctx } = makeNativeCtx(); + const first = installGrokPlugin(ctx, { globalGrokDirOverride: grokDir }); + expect(first.ok).toBe(true); + if (!first.ok) return; + const firstMtime = statSync(first.paths.globalHookFilePath).mtimeMs; + + await new Promise((r) => setTimeout(r, 20)); + + const second = installGrokPlugin(ctx, { globalGrokDirOverride: grokDir }); + expect(second.ok).toBe(true); + if (!second.ok) return; + expect(statSync(second.paths.globalHookFilePath).mtimeMs).toBe(firstMtime); + }); + + it("does not touch any project-level paths", () => { + const { grokDir, ctx } = makeNativeCtx(); + const projectDir = mkdtempSync(join(tmpdir(), "lightcode-grok-proj-")); + mkdirSync(join(projectDir, ".grok"), { recursive: true }); + + const result = installGrokPlugin(ctx, { globalGrokDirOverride: grokDir }); + expect(result.ok).toBe(true); + + expect(existsSync(join(projectDir, ".grok", "hooks"))).toBe(false); + }); +}); + +describe("getGrokPluginPaths", () => { + it("returns staging dir under provided baseDir for native ctx", () => { + const baseDir = mkdtempSync(join(tmpdir(), "lightcode-grok-paths-")); + const paths = getGrokPluginPaths({ envKind: "posix", baseDir }); + expect(paths.pluginDir).toBe(join(baseDir, "agent-plugins", "grok")); + }); +}); + +describe("isGrokPluginInstalled", () => { + function stage(parts: { + manifest?: boolean; + forward?: boolean; + runtime?: boolean; + wrapper?: boolean; + }) { + const baseDir = mkdtempSync(join(tmpdir(), "lightcode-grok-verify-")); + const pluginDir = join(baseDir, "agent-plugins", "grok"); + mkdirSync(pluginDir, { recursive: true }); + if (parts.manifest) { + writeFileSync(join(pluginDir, "plugin.json"), '{"name":"x","version":"9.9.9"}'); + } + if (parts.forward) { + writeFileSync(join(pluginDir, "forward.mjs"), "// noop"); + } + if (parts.runtime) { + writeFileSync(join(pluginDir, "lightcode-hook-runtime.mjs"), "// noop runtime"); + } + if (parts.wrapper) { + const wrapperName = process.platform === "win32" ? "lightcode-hook.cmd" : "lightcode-hook.sh"; + writeFileSync(join(pluginDir, wrapperName), "#!/bin/sh\nexit 0\n"); + } + return { baseDir, ctx: { envKind: "posix" as const, baseDir } }; + } + + it("returns installed:false when staging assets are missing", () => { + expect( + isGrokPluginInstalled(stage({ forward: true, runtime: true, wrapper: true }).ctx), + ).toEqual({ installed: false }); + expect( + isGrokPluginInstalled(stage({ manifest: true, runtime: true, wrapper: true }).ctx), + ).toEqual({ installed: false }); + expect( + isGrokPluginInstalled(stage({ manifest: true, forward: true, wrapper: true }).ctx), + ).toEqual({ installed: false }); + expect( + isGrokPluginInstalled(stage({ manifest: true, forward: true, runtime: true }).ctx), + ).toEqual({ installed: false }); + }); +}); diff --git a/src/supervisor/agents/grok/plugin/install.ts b/src/supervisor/agents/grok/plugin/install.ts new file mode 100644 index 00000000..48349852 --- /dev/null +++ b/src/supervisor/agents/grok/plugin/install.ts @@ -0,0 +1,383 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { toWslUncPath } from "@/shared/wsl"; +import { resolveWslHomeDirectory, type AgentEnvContext } from "../../base"; +import { + FORWARD_RUNTIME_FILE, + buildNativeHookCommandHeads, + buildWslHookCommandHead, + copyForwardRuntimeFile, + copyPluginAssetsIfStale, + createPluginSourceResolver, + ctxCacheKey, + getNativePluginBaseDir, + getWslPluginBaseDirs, + isWslPluginContext, + memoByCtx, + readBundledPluginVersion, + readPluginManifest, + stagePluginAssetsToWsl, + verifyStagedPluginAt, + writeNativeHookWrapper, + type PluginManifest, +} from "../../plugin/installerBase"; + +/** + * Grok CLI plugin installer. + * + * Two writes per install: + * 1. **Plugin staging** under `~/.lightcode/agent-plugins/grok/` — copies + * `forward.mjs` + `plugin.json` + the shared forwarder runtime + the + * native wrapper script. Same shape as Claude/Codex/Gemini/Copilot. + * 2. **Global hook config** at `~/.grok/hooks/lightcode-status.json`. Grok + * loads global hooks at every session and always trusts them — no + * `/hooks-trust` prompt is required. Done at install time, not per-spawn. + * + * Both files are owned by Lightcode — we replace them on reinstall and never + * merge into user-authored config. + */ + +export interface GrokPluginPaths { + /** + * Directory containing forward.mjs, plugin.json, and the native wrapper. + * For WSL contexts this is a Linux path inside the distro; native fs APIs + * must use `toWslUncPath(distro, ...)` instead. + */ + pluginDir: string; + /** Absolute path of the user-global hook config file written by install. */ + globalHookFilePath: string; + /** Plugin semver from plugin.json. */ + version: string; +} + +const GROK_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "Notification"] as const; + +const GLOBAL_HOOK_FILENAME = "lightcode-status.json"; +const GLOBAL_HOOK_DIR_NAME = "hooks"; +const GLOBAL_GROK_DIR_NAME = ".grok"; +const HOOK_TIMEOUT_SEC = 5; + +const callerDir = + typeof __dirname !== "undefined" + ? __dirname + : dirname(fileURLToPath(import.meta.url ?? "file://")); + +const resolveSourceDir = createPluginSourceResolver({ + kind: "grok", + sourceEnvVar: "LIGHTCODE_GROK_PLUGIN_SOURCE", + callerDir, +}); + +export function readBundledGrokPluginVersion(): string { + return readBundledPluginVersion(resolveSourceDir); +} + +function nativeGlobalGrokDir(): string { + return join(homedir(), GLOBAL_GROK_DIR_NAME); +} + +function wslGlobalGrokDir(distro: string): string { + const home = resolveWslHomeDirectory(distro); + return home ? `${home}/${GLOBAL_GROK_DIR_NAME}` : ""; +} + +function computeGrokPluginPaths(ctx?: AgentEnvContext): GrokPluginPaths { + if (isWslPluginContext(ctx)) { + const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok"); + if (!wsl) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; + let version = "0.0.0"; + try { + version = readPluginManifest(wsl.uncBase).version; + } catch { + // staged manifest missing or distro unreachable + } + const grokDir = wslGlobalGrokDir(ctx.wslDistro); + return { + pluginDir: wsl.linuxBase, + globalHookFilePath: grokDir + ? `${grokDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}` + : "", + version, + }; + } + const pluginDir = getNativePluginBaseDir("grok", ctx?.baseDir); + let version = "0.0.0"; + try { + version = readPluginManifest(pluginDir).version; + } catch { + // staged manifest missing; caller should run installGrokPlugin first. + } + return { + pluginDir, + globalHookFilePath: join(nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME), + version, + }; +} + +const grokPluginPathsMemo = memoByCtx(computeGrokPluginPaths, ctxCacheKey); + +export function getGrokPluginPaths(ctx?: AgentEnvContext): GrokPluginPaths { + return grokPluginPathsMemo.call(ctx); +} + +export interface InstallGrokPluginOptions { + /** + * Absolute path to the Node binary the staged hook command should use. + * + * - **WSL contexts:** required. Comes from `resolveNodeForDistro`. + * - **Native contexts:** optional. When provided (preferred), the wrapper + * exec's the bare Node binary directly; otherwise it falls back to + * `ELECTRON_RUN_AS_NODE=1` against the bundled Electron binary. + */ + resolvedNodePath?: string | undefined; + /** + * Override `~/.grok` (or the WSL distro equivalent) when writing the global + * hook file. Tests pass a temp dir to avoid touching the user's real Grok + * config; production calls leave this undefined. + */ + globalGrokDirOverride?: string; +} + +export function installGrokPlugin( + ctx?: AgentEnvContext, + options?: InstallGrokPluginOptions, +): { ok: true; paths: GrokPluginPaths; version: string } | { ok: false; reason: string } { + let sourceDir: string; + try { + sourceDir = resolveSourceDir(); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + + let manifest: PluginManifest; + try { + manifest = readPluginManifest(sourceDir); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + + if (isWslPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: + "WSL Grok plugin install requires a resolved node path; the adapter must call resolveNodeForDistro before installing.", + }; + } + return installGrokPluginWsl( + ctx.wslDistro, + sourceDir, + manifest, + options.resolvedNodePath, + options.globalGrokDirOverride, + ); + } + + const pluginDir = getNativePluginBaseDir("grok", ctx?.baseDir); + mkdirSync(pluginDir, { recursive: true }); + copyPluginAssetsIfStale(sourceDir, pluginDir); + copyForwardRuntimeFile(pluginDir); + const wrapperPath = writeNativeHookWrapper(pluginDir, { + ...(options?.resolvedNodePath ? { nodePath: options.resolvedNodePath } : {}), + }); + + const globalGrokDir = options?.globalGrokDirOverride + ? resolve(options.globalGrokDirOverride) + : nativeGlobalGrokDir(); + const hookFilePath = join(globalGrokDir, GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME); + + const nativeCommands = buildNativeHookCommandHeads(wrapperPath); + + const writeResult = writeGrokHookFileIfChanged(hookFilePath, { + command: nativeCommands.command, + }); + if (!writeResult.ok) return writeResult; + + console.log( + [ + `[supervisor] Grok hook plugin staged v${manifest.version}`, + ` pluginDir: ${pluginDir}`, + ` hookFile: ${hookFilePath}`, + ].join("\n"), + ); + + return { + ok: true, + version: manifest.version, + paths: { pluginDir, globalHookFilePath: hookFilePath, version: manifest.version }, + }; +} + +function installGrokPluginWsl( + distro: string, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, + globalGrokDirOverride: string | undefined, +): { ok: true; paths: GrokPluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToWsl(distro, sourceDir, "grok", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const linuxForward = `${staged.linuxPluginDir}/forward.mjs`; + const linuxGrokDir = globalGrokDirOverride ?? `${staged.deploy.home}/${GLOBAL_GROK_DIR_NAME}`; + const linuxHookFilePath = `${linuxGrokDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`; + const uncHookFilePath = toWslUncPath(distro, linuxHookFilePath); + + const command = buildWslHookCommandHead(resolvedNodePath, linuxForward); + + const writeResult = writeGrokHookFileIfChanged(uncHookFilePath, { command }); + if (!writeResult.ok) { + return { + ok: false, + reason: `failed to write Grok hook file at ${linuxHookFilePath} in wsl distro ${distro}: ${writeResult.reason}`, + }; + } + + console.log( + [ + `[supervisor] Grok hook plugin staged v${manifest.version} in WSL distro ${distro}`, + ` pluginDir: ${staged.linuxPluginDir}`, + ` hookFile: ${linuxHookFilePath}`, + ].join("\n"), + ); + + return { + ok: true, + version: manifest.version, + paths: { + pluginDir: staged.linuxPluginDir, + globalHookFilePath: linuxHookFilePath, + version: manifest.version, + }, + }; +} + +const GROK_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const; + +export function isGrokPluginInstalled(ctx?: AgentEnvContext): { + installed: boolean; + version?: string; +} { + if (isWslPluginContext(ctx)) { + const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok"); + if (!wsl) return { installed: false }; + const grokDir = wslGlobalGrokDir(ctx.wslDistro); + const hookFile = grokDir + ? toWslUncPath(ctx.wslDistro, `${grokDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`) + : ""; + return verifyStagedPluginAt(wsl.uncBase, "wsl", { + assets: GROK_VERIFY_ASSETS, + extraCheck: () => hookFile.length > 0 && hookFileMatchesLightcode(hookFile), + }); + } + const hookFile = join(nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME); + return verifyStagedPluginAt(getNativePluginBaseDir("grok", ctx?.baseDir), "native", { + assets: GROK_VERIFY_ASSETS, + extraCheck: () => hookFileMatchesLightcode(hookFile), + }); +} + +/** + * Match either the WSL command shape (absolute node path + forward.mjs) or + * the native shape (`lightcode-hook.{sh,cmd,ps1}` wrapper). Used to confirm + * the hook file points at our staged wrapper and not at a stale or + * user-authored entry. + */ +const LIGHTCODE_GROK_HOOK_RE = + /agent-plugins(?:[/\\]+)grok(?:[/\\]+)(?:forward\.mjs|lightcode-hook\.(?:sh|cmd|ps1))/; + +function hookFileMatchesLightcode(path: string): boolean { + if (!existsSync(path)) return false; + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as { hooks?: Record }; + if (!parsed.hooks || typeof parsed.hooks !== "object") return false; + for (const event of GROK_HOOK_EVENTS) { + const groups = parsed.hooks[event]; + if (!Array.isArray(groups) || groups.length === 0) return false; + const found = groups.some((group) => { + if (!group || typeof group !== "object") return false; + const hookEntries = (group as { hooks?: unknown }).hooks; + if (!Array.isArray(hookEntries)) return false; + return hookEntries.some((hook) => { + if (!hook || typeof hook !== "object") return false; + const command = (hook as { command?: unknown }).command; + return typeof command === "string" && LIGHTCODE_GROK_HOOK_RE.test(command); + }); + }); + if (!found) return false; + } + return true; + } catch { + return false; + } +} + +// ── Hook config rendering / write ───────────────────────────────────────── + +interface GrokHookCommand { + type: "command"; + command: string; + timeout: number; +} + +interface GrokHookEntry { + hooks: GrokHookCommand[]; +} + +interface GrokHookConfig { + hooks: Record; +} + +/** + * Render the user-global hook config that points the Grok CLI at our staged + * forwarder. Single `command` field per hook entry — Grok's hook schema + * matches Claude Code's: one shell string the runner exec's with the event + * name appended as `argv[2]`. Timeout is the Grok-doc default (5 s). + */ +export function renderGrokHookConfig(input: { command: string }): GrokHookConfig { + const hooks: Record = {}; + for (const event of GROK_HOOK_EVENTS) { + hooks[event] = [ + { + hooks: [ + { + type: "command", + command: `${input.command} ${event}`, + timeout: HOOK_TIMEOUT_SEC, + }, + ], + }, + ]; + } + return { hooks }; +} + +function writeGrokHookFileIfChanged( + hookFilePath: string, + input: { command: string }, +): { ok: true } | { ok: false; reason: string } { + const serialized = `${JSON.stringify(renderGrokHookConfig(input), null, 2)}\n`; + try { + const existing = readFileSync(hookFilePath, "utf8"); + if (existing === serialized) return { ok: true }; + } catch { + // file missing or unreadable; fall through to write + } + try { + mkdirSync(dirname(hookFilePath), { recursive: true }); + writeFileSync(hookFilePath, serialized, "utf8"); + return { ok: true }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +export { GROK_HOOK_EVENTS, GLOBAL_HOOK_FILENAME, GLOBAL_HOOK_DIR_NAME }; diff --git a/src/supervisor/agents/grok/plugin/intentMap.ts b/src/supervisor/agents/grok/plugin/intentMap.ts new file mode 100644 index 00000000..d3408074 --- /dev/null +++ b/src/supervisor/agents/grok/plugin/intentMap.ts @@ -0,0 +1,62 @@ +import type { AgentEventIntent } from "@/shared/contracts"; + +export interface GrokHookPayload { + hookEventName?: string; + notificationType?: string; + notification_type?: string; + type?: string; + message?: string; +} + +function normalizeEventName(eventName: string, payload: GrokHookPayload | undefined): string { + return payload?.hookEventName ?? eventName; +} + +function notificationNeedsApproval(payload: GrokHookPayload | undefined): boolean { + const notificationType = `${ + payload?.notificationType ?? payload?.notification_type ?? payload?.type ?? "" + }`.toLowerCase(); + const message = `${payload?.message ?? ""}`.toLowerCase(); + return ( + notificationType.includes("permission") || + notificationType.includes("approval") || + message.includes("permission") || + message.includes("approval") + ); +} + +/** + * Mirror of the hook surface registered in `install.ts`. Grok's TUI fires + * lifecycle events in PascalCase on `argv[2]` (the form we register in the + * hooks JSON) and includes the snake_case `hookEventName` on the stdin + * payload. We accept either casing. + * + * - SessionStart → `session.started` (bookkeeping / install proof-of-life) + * - UserPromptSubmit → `session.turn_started` (turn open) + * - Stop → `session.turn_finished` (turn close — redundant with OSC 9;4 but authoritative when present) + * - Notification → `session.needs_approval` (only when payload indicates approval/permission) + * + * `PreToolUse` / `PostToolUse` are intentionally not registered: they all + * converge on `session.turn_started`, and OSC parsing already provides a + * working/idle edge from Grok's braille spinner + iTerm2 OSC 9;4 progress. + */ +export function grokIntentFor( + eventName: string, + payload: GrokHookPayload | undefined, +): AgentEventIntent | undefined { + const name = normalizeEventName(eventName, payload).toLowerCase(); + switch (name) { + case "sessionstart": + case "session_start": + return "session.started"; + case "userpromptsubmit": + case "user_prompt_submit": + return "session.turn_started"; + case "stop": + return "session.turn_finished"; + case "notification": + return notificationNeedsApproval(payload) ? "session.needs_approval" : undefined; + default: + return undefined; + } +} diff --git a/src/supervisor/agents/grok/plugin/plugin.json b/src/supervisor/agents/grok/plugin/plugin.json new file mode 100644 index 00000000..7bb28028 --- /dev/null +++ b/src/supervisor/agents/grok/plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "lightcode-status-grok", + "version": "1.2.0", + "description": "Grok CLI lifecycle hook forwarder for Lightcode thread status", + "author": "Lightcode", + "license": "Apache-2.0" +} diff --git a/src/supervisor/agents/grok/sessionFiles.ts b/src/supervisor/agents/grok/sessionFiles.ts new file mode 100644 index 00000000..46171802 --- /dev/null +++ b/src/supervisor/agents/grok/sessionFiles.ts @@ -0,0 +1,291 @@ +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { Worker } from "node:worker_threads"; +import type { ProjectLocation, SessionRef } from "@/shared/contracts"; +import { + createKnownSessionRef, + listSessionDir, + readWslCommandOutput, + resolveWslHomeDirectory, + statSessionPaths, + watchSessionPaths, +} from "../base"; +import { resolveAgentBinaryPath } from "../binaryResolver"; + +const GROK_SESSIONS_ROOT = join(homedir(), ".grok", "sessions"); + +// Module-level snapshot captured right before we spawn a PTY for a fresh Grok launch. +// This lets discoverSessionRef reliably identify the *new* session dir that Grok +// creates for this particular launch (instead of an older one for the same cwd). +let preSpawnSessionIds = new Set(); +let preSpawnCwdKey: string | null = null; + +function encodeCwdKey(cwd: string): string { + // Grok stores sessions under ~/.grok/sessions// + return encodeURIComponent(cwd); +} + +function getGrokCwdSessionsDir(location: ProjectLocation, cwd: string): string | null { + if (location.kind === "wsl") { + const home = resolveWslHomeDirectory(location.distro); + if (!home) return null; + return `${home}/.grok/sessions/${encodeCwdKey(cwd)}`; + } + return join(GROK_SESSIONS_ROOT, encodeCwdKey(cwd)); +} + +function getGrokSessionsRoot(location: ProjectLocation): string | null { + if (location.kind === "wsl") { + const home = resolveWslHomeDirectory(location.distro); + return home ? `${home}/.grok/sessions` : null; + } + return GROK_SESSIONS_ROOT; +} + +/** + * Call this from buildLaunchArgv (and optionally buildResumeArgv) immediately + * before spawning the grok PTY. It records what sessions already exist for + * this cwd so that discover can tell the brand-new one apart. + * + * Sync on every platform — runs once per launch. WSL uses a one-shot + * `wsl.exe ls` (~50–100ms) since `buildLaunchArgv` itself is synchronous; + * the async discover path uses the in-distro bridge. + */ +export function snapshotGrokPreSpawnSessions(location: ProjectLocation, cwd: string): void { + preSpawnSessionIds = new Set(); + preSpawnCwdKey = null; + + const dir = getGrokCwdSessionsDir(location, cwd); + if (!dir) return; + + if (location.kind === "wsl") { + const result = readWslCommandOutput(location.distro, "sh", [ + "-c", + `[ -d ${shellQuote(dir)} ] && ls -1 -- ${shellQuote(dir)} 2>/dev/null || true`, + ]); + if (!result.ok) return; + preSpawnCwdKey = encodeCwdKey(cwd); + for (const name of result.stdout.split(/\r?\n/)) { + const trimmed = name.trim(); + if (isUuid(trimmed)) preSpawnSessionIds.add(trimmed); + } + return; + } + + if (!existsSync(dir)) return; + preSpawnCwdKey = encodeCwdKey(cwd); + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && isUuid(entry.name)) { + preSpawnSessionIds.add(entry.name); + } + } + } catch { + // Best effort; discovery will still be able to pick a recent dir. + } +} + +function shellQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +function isUuid(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +/** + * Return the most recently modified Grok session directory (its basename = the + * session UUID) under the given location/cwd that was *not* present in the + * pre-snapshot. Falls back to the absolute newest UUID dir if the pre-snapshot + * is empty. + * + * Native uses `readdirSync` + `statSync`. WSL routes through the in-distro + * bridge (`listSessionDir` + batched `statSessionPaths`) so each discovery + * round-trip costs ~10ms (HTTP loopback) instead of ~100ms (`wsl.exe`). + */ +export async function discoverGrokSessionRef( + location: ProjectLocation, + cwd: string, +): Promise { + const dir = getGrokCwdSessionsDir(location, cwd); + if (!dir) return undefined; + const key = encodeCwdKey(cwd); + + const entries = await listSessionDir(location, dir); + if (!entries) return undefined; + + const candidateNames = entries + .filter((e) => e.type === "directory" && isUuid(e.name)) + .map((e) => e.name) + .filter((name) => !(preSpawnCwdKey === key && preSpawnSessionIds.has(name))); + + if (candidateNames.length === 0) return undefined; + + const paths = candidateNames.map((name) => `${dir}/${name}`); + const stats = await statSessionPaths(location, paths); + const ranked = candidateNames + .map((name) => ({ id: name, mtime: stats.get(`${dir}/${name}`)?.mtimeMs ?? 0 })) + .sort((a, b) => b.mtime - a.mtime); + + const winner = ranked[0]; + return winner ? createKnownSessionRef(winner.id) : undefined; +} + +export function makeGrokDiscoverSessionRef() { + return async (location: ProjectLocation): Promise => { + const cwd = location.kind === "wsl" ? location.linuxPath : location.path; + return discoverGrokSessionRef(location, cwd); + }; +} + +/** + * Absolute paths to watch for new/updated Grok sessions for this location/cwd. + * Native paths for windows/posix; Linux paths inside the distro for WSL. + * Prefers the specific dir; falls back to the parent sessions + * root when that subdir doesn't exist yet so the first session creation + * still wakes the watcher. + */ +export function resolveGrokSessionsWatchPaths(location: ProjectLocation, cwd: string): string[] { + const dir = getGrokCwdSessionsDir(location, cwd); + if (location.kind === "wsl") { + const root = getGrokSessionsRoot(location); + return [dir ?? undefined, root ?? undefined].filter((p): p is string => Boolean(p)); + } + if (dir && existsSync(dir)) return [dir]; + return [GROK_SESSIONS_ROOT]; +} + +export function makeGrokWatchSessionRef() { + return (location: ProjectLocation, onChanged: () => void): (() => void) | undefined => { + const cwd = location.kind === "wsl" ? location.linuxPath : location.path; + const paths = resolveGrokSessionsWatchPaths(location, cwd); + if (paths.length === 0) return undefined; + + const label = `grok:${location.kind === "wsl" ? "wsl:" + location.distro : location.kind}`; + return watchSessionPaths(location, paths, onChanged, label); + }; +} + +/** + * Worker payload: spawn `grok [flags] agent stdio`, drive `initialize` → + * `authenticate` → `session/new`, write the returned UUID into the shared + * buffer, then notify the parent. Runs inside a `worker_threads.Worker` so + * that `mintGrokSessionIdViaAcpSync` can block via `Atomics.wait` without + * changing the synchronous `AgentLauncher` interface. + * + * NOTE: `spawnSync` is unusable here because Grok 0.1.218 closes the ACP + * connection as soon as stdin EOFs — usually before it has processed the + * `session/new` request. We need to keep stdin open until id:3 lands. + */ +const MINT_WORKER_SCRIPT = ` +const { workerData } = require("worker_threads"); +const { spawn } = require("child_process"); +const { sab, binary, args, cwd } = workerData; +const signalView = new Int32Array(sab, 0, 1); +const idView = new Uint8Array(sab, 4, 64); + +let resolved = false; +function finish(sessionId) { + if (resolved) return; + resolved = true; + if (sessionId) { + const buf = Buffer.from(sessionId, "utf8"); + for (let i = 0; i < buf.length && i < 64; i++) idView[i] = buf[i]; + } + Atomics.store(signalView, 0, 1); + Atomics.notify(signalView, 0); + try { p.kill(); } catch {} +} + +const p = spawn(binary, [...args, "agent", "stdio"], { cwd, stdio: ["pipe","pipe","pipe"] }); +// Stdin EOF on terminate is the documented Grok-exit signal, but kill the +// child explicitly too so a future Grok build that keeps the connection open +// on EOF cannot orphan the process. +process.on("exit", () => { try { p.kill(); } catch {} }); +let buf = ""; +p.stdout.on("data", (chunk) => { + buf += chunk.toString("utf8"); + let nl; + while ((nl = buf.indexOf("\\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 3 && msg.result && typeof msg.result.sessionId === "string") { + finish(msg.result.sessionId); + return; + } + } catch { /* skip non-JSON noise */ } + } +}); +p.on("error", () => finish()); +p.on("close", () => finish()); + +const reqs = [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientInfo: { name: "lightcode-grok-mint", version: "1" }, clientCapabilities: { auth: { terminal: true } } } }, + { jsonrpc: "2.0", id: 2, method: "authenticate", params: { methodId: "cached_token" } }, + { jsonrpc: "2.0", id: 3, method: "session/new", params: { cwd, mcpServers: [] } }, +]; +for (const r of reqs) p.stdin.write(JSON.stringify(r) + "\\n"); +`; + +/** + * Synchronously mint a Grok session ID by spinning up `grok agent stdio` long + * enough to drive `initialize` → `authenticate` → `session/new`. The returned + * UUID is then used with the PTY `-r ` flag so the TUI loads that session + * directly instead of rendering the welcome menu (which would otherwise + * suppress our initial-prompt delivery path). + * + * `extraLaunchArgs` should be the output of `buildGrokAcpArgs(config)` so the + * minted session is born with the right model / effort / permission-mode / + * always-approve state already recorded in `summary.json`. + * + * Returns `undefined` on any failure (timeout, auth error, JSON parse, etc.). + * Callers must treat undefined as "no minted ID" and proceed without `-r`. + * + * WSL is skipped — minting would require routing the ACP child through + * `wsl.exe`, which is out of scope here. + */ +export function mintGrokSessionIdViaAcpSync( + location: ProjectLocation, + timeoutMs = 4500, + extraLaunchArgs: string[] = [], +): string | undefined { + if (location.kind === "wsl") return undefined; + + // Use the absolute path resolved during detection. Packaged Electron apps + // start with a minimal PATH that may exclude Homebrew/asdf/etc., so a bare + // "grok" in the worker's spawn would fail even though detection found the + // binary. Mirrors how createCursorChatSync passes resolveAgentBinaryPath(). + const binary = resolveAgentBinaryPath(location, "grok") ?? "grok"; + + // 4 bytes for the Atomics signal (Int32), 64 bytes for the UUID payload. + const sab = new SharedArrayBuffer(4 + 64); + const signalView = new Int32Array(sab, 0, 1); + const idView = new Uint8Array(sab, 4, 64); + + const worker = new Worker(MINT_WORKER_SCRIPT, { + eval: true, + workerData: { sab, binary, args: extraLaunchArgs, cwd: location.path }, + }); + worker.on("error", () => { + Atomics.store(signalView, 0, 1); + Atomics.notify(signalView, 0); + }); + + Atomics.wait(signalView, 0, 0, timeoutMs); + + const terminator = idView.indexOf(0); + const length = terminator >= 0 ? terminator : idView.length; + const sessionId = length > 0 ? Buffer.from(idView.slice(0, length)).toString("utf8") : undefined; + + // Best-effort cleanup. terminate() is async; we don't await because the + // worker is fully self-contained and its child has been signalled to die. + worker.terminate(); + + return sessionId && isUuid(sessionId) ? sessionId : undefined; +} diff --git a/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs b/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs index 8d5cfe58..a247ec5c 100644 --- a/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs +++ b/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs @@ -16,12 +16,15 @@ */ import { readFileSync } from "node:fs"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; const PROTOCOL_VERSION = 1; const MAX_STDIN_CHARS = 2 * 1024 * 1024; +const POST_TIMEOUT_MS = 2_000; export function readPluginVersionFromManifest(importMetaUrl) { try { @@ -76,16 +79,60 @@ export async function readStdin() { return readJsonFromStream(process.stdin); } -export async function postWithRetry(url, headers, body, attempts = 2) { - let lastError; - for (let i = 0; i < attempts; i += 1) { +/** + * POST `body` to `url` once, returning `{ ok, status }`. Uses the bare + * `node:http(s)` modules to avoid the per-process undici (fetch) cold-init + * cost, which lands ~20–30 ms on every spawn. The supervisor's hook ingress + * runs on `127.0.0.1`, so the loopback path is fast (~3–8 ms) once the + * connection is open. + */ +function postOnce(url, headers, body) { + return new Promise((resolveResult) => { + let parsed; try { - const response = await fetch(url, { method: "POST", headers, body }); - if (response.ok || response.status === 426) return; - lastError = new Error(`HTTP ${response.status}`); + parsed = new URL(url); } catch (error) { - lastError = error; + resolveResult({ ok: false, error }); + return; } + const transport = parsed.protocol === "https:" ? httpsRequest : httpRequest; + const req = transport( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), + path: `${parsed.pathname}${parsed.search}`, + method: "POST", + headers: { ...headers, "content-length": Buffer.byteLength(body) }, + }, + (res) => { + res.resume(); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status >= 200 && status < 300) { + resolveResult({ ok: true, status }); + } else if (status === 426) { + resolveResult({ ok: true, status }); + } else { + resolveResult({ ok: false, status, error: new Error(`HTTP ${status}`) }); + } + }); + }, + ); + req.setTimeout(POST_TIMEOUT_MS, () => { + req.destroy(new Error("hook POST timeout")); + }); + req.on("error", (error) => resolveResult({ ok: false, error })); + req.write(body); + req.end(); + }); +} + +export async function postWithRetry(url, headers, body, attempts = 2) { + let lastError; + for (let i = 0; i < attempts; i += 1) { + const result = await postOnce(url, headers, body); + if (result.ok) return; + lastError = result.error ?? new Error(`HTTP ${result.status ?? 0}`); if (i + 1 < attempts) await sleep(50); } if (lastError && hookDebugEnabled()) { diff --git a/src/supervisor/agents/registry.ts b/src/supervisor/agents/registry.ts index 88cbb26b..db658eb7 100644 --- a/src/supervisor/agents/registry.ts +++ b/src/supervisor/agents/registry.ts @@ -16,6 +16,7 @@ import { createCopilotAdapter } from "./copilot"; import { createCodexAdapter } from "./codex"; import { createCursorAdapter } from "./cursor"; import { createGeminiAdapter } from "./gemini"; +import { createGrokAdapter } from "./grok"; import { createOpenCodeAdapter } from "./opencode"; export function createAgentRegistry(): AgentAdapter[] { @@ -33,6 +34,7 @@ export function buildAgentRegistry(userInstances: AgentInstanceConfig[]): AgentA createCopilotAdapter(), createCodexAdapter(), createGeminiAdapter(), + createGrokAdapter(), createAntigravityAdapter(), createCursorAdapter(), createOpenCodeAdapter(), diff --git a/src/supervisor/runtime.ts b/src/supervisor/runtime.ts index fdf5d50b..685b0a90 100644 --- a/src/supervisor/runtime.ts +++ b/src/supervisor/runtime.ts @@ -163,7 +163,12 @@ import { updateAcpRegistryAgent as updateAcpRegistryAgentFromRegistry, } from "./agents/acpRegistry"; import { prefetchNativeNodeRuntime } from "./runtime/prefetchNativeNode"; -import { readWslCommandOutputAsync, type AgentAdapter, type AgentEnvContext } from "./agents/base"; +import { + readWslCommandOutputAsync, + setSessionFsBridgeClient, + type AgentAdapter, + type AgentEnvContext, +} from "./agents/base"; import { getLatestVersionForAdapter, runUpdateCommandWithFallback } from "./agents/updateAgent"; import { clearAgentBinaryPathCache } from "./agents/binaryResolver"; import { generateCommitMessage } from "./commitMessageGenerator"; @@ -360,6 +365,7 @@ export class SupervisorRuntime { this.gitCheckpointService.setWslClient(client); this.projectTreeService.setWslClient(client); this._projectWatcher?.setWslClient(client); + setSessionFsBridgeClient(client); } this.cliHookPluginCoordinator.startIngress(); diff --git a/src/supervisor/runtime/threadOutputPipeline.ts b/src/supervisor/runtime/threadOutputPipeline.ts index 52fe3d49..09c6bda7 100644 --- a/src/supervisor/runtime/threadOutputPipeline.ts +++ b/src/supervisor/runtime/threadOutputPipeline.ts @@ -665,6 +665,16 @@ export class ThreadOutputPipeline { this.options.onStartQueuedLaunchPrompt(session); } + // Mirror the hook-owned fallback (lines 461-466): adapters without + // `detectTerminalStatus` (e.g. grok) rely on `isReadyForInitialPrompt` + // to flush the deferred initial prompt, otherwise it sits unsent. + if ( + session.pendingTerminalPrompt && + session.adapter.isReadyForInitialPrompt?.(strippedData) + ) { + this.flushPendingTerminalWritesIfIdle(session); + } + if (hint?.status === "idle") { this.flushPendingTerminalWritesIfIdle(session); } From 014a4f6b3e273f18c59acbc611fd7e0e37260fe0 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 24 May 2026 13:34:47 -0700 Subject: [PATCH 2/2] test(integration): add provider lifecycle integration tests - Add providers-lifecycle.integration.test.ts to test start/close/resume across agents - Implement vitest.integration.config.ts to run integration tests sequentially - Include tests directory and integration config in tsconfig.json - Add test:integration:providers script to package.json --- package.json | 1 + .../providers-lifecycle.integration.test.ts | 444 ++++++++++++++++++ tsconfig.json | 9 +- vitest.integration.config.ts | 27 ++ 4 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 tests/integration/providers-lifecycle.integration.test.ts create mode 100644 vitest.integration.config.ts diff --git a/package.json b/package.json index 3abb6155..b6ea63e4 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "fmt:check": "oxfmt --check .", "test": "vitest run", "test:perf:cli-hook": "vitest run src/supervisor/runtime/cliHookEventChain.perf.test.ts", + "test:integration:providers": "vitest run --config vitest.integration.config.ts", "update-server": "node scripts/update-server.mjs", "db:clone": "node scripts/clone-db.mjs", "prepare": "husky" diff --git a/tests/integration/providers-lifecycle.integration.test.ts b/tests/integration/providers-lifecycle.integration.test.ts new file mode 100644 index 00000000..73026dfb --- /dev/null +++ b/tests/integration/providers-lifecycle.integration.test.ts @@ -0,0 +1,444 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { + AgentStatus, + ProjectLocation, + SessionRef, + StartThreadPayload, + ThreadConfig, +} from "@/shared/contracts"; +import type { SupervisorEvent } from "@/shared/ipc"; +import type { AgentAdapter } from "@/supervisor/agents/base"; +import { createAgentRegistry } from "@/supervisor/agents/registry"; +import { SupervisorRuntime } from "@/supervisor/runtime"; + +// Live-CLI integration: for each adapter in `createAgentRegistry()`, this test +// starts a real thread with a cheap model, waits for sessionRef discovery, +// closes the thread, resumes it, and asserts the original prompt is visible in +// the resumed PTY's terminal scrollback. Providers that aren't installed or +// authenticated are skipped — the test fails only when an installed + +// authenticated provider loses the initial message across close/resume. + +const PROMPT_TOKEN = `lightcode-int-${randomUUID().slice(0, 8)}`; +const PROMPT = `Reply with the single word OK. (token: ${PROMPT_TOKEN})`; +const SESSION_REF_TIMEOUT_MS = 120_000; +const TURN_COMPLETE_TIMEOUT_MS = 180_000; +const SCROLLBACK_WAIT_TIMEOUT_MS = 120_000; + +// Hand-picked cheapest model per provider. For dynamic-model providers +// (Codex / Copilot / Grok / OpenCode), we fall back to scanning the detected +// capabilities for a "mini/flash/lite/haiku/small/fast" name, then the first +// model. None of these defaults are guaranteed to exist on every host — the +// test will surface a clear error if the chosen model is rejected by the CLI. +const PREFERRED_MODEL: Record = { + claude: "haiku", + cursor: "auto", + antigravity: "auto", + opencode: "opencode/big-pickle", +}; + +const CHEAP_NAME_HINTS = ["haiku", "mini", "flash-lite", "flash", "lite", "small", "fast", "nano"]; + +// First-run interactive prompts we know how to answer in the test. The +// scrollback contains ANSI escapes (cursor positioning, colors), so the +// matcher only looks at decoded text fragments. Each entry sends its keystroke +// once and is then disarmed for the rest of the run. +interface DialogResponder { + needle: RegExp; + response: string; + reason: string; +} + +const KIND_DIALOG_RESPONDERS: Record = { + // Codex >= 0.130 gates first-run on a "Hooks need review" dialog whenever + // CODEX_HOME contains hooks the user hasn't accepted. The supervisor stages + // a fresh hook bundle into a temp CODEX_HOME each test invocation, so we + // always see this prompt; auto-select "2. Trust all and continue". + codex: [ + { + needle: /Hooks\s+need\s+review/i, + response: "2\r", + reason: "codex: accept hooks trust dialog", + }, + ], +}; + +function decodeScrollbackText(scrollback: string): string { + // Strip CSI / OSC / private-mode escape sequences so simple substring or + // regex matches find the underlying text fragments. Regexes are built + // dynamically so the source contains no literal control bytes. + const ESC = String.fromCharCode(0x1b); + const BEL = String.fromCharCode(0x07); + const osc = new RegExp(ESC + "\\][^" + BEL + ESC + "]*(" + BEL + "|" + ESC + "\\\\)", "g"); + const csi = new RegExp(ESC + "\\[[0-?]*[ -/]*[@-~]", "g"); + const privateMode = new RegExp(ESC + "[=>]", "g"); + return scrollback.replace(osc, "").replace(csi, "").replace(privateMode, ""); +} + +function pickCheapModel(adapter: AgentAdapter, status: AgentStatus): string | undefined { + const preferred = PREFERRED_MODEL[adapter.kind]; + if (preferred) return preferred; + + const models = status.capabilities.models ?? adapter.capabilities.models ?? []; + for (const hint of CHEAP_NAME_HINTS) { + const match = models.find((m) => m.id.toLowerCase().includes(hint)); + if (match) return match.id; + } + return models[0]?.id; +} + +function makeProjectLocation(cwd: string): ProjectLocation { + if (process.platform === "win32") { + return { kind: "windows", path: cwd }; + } + return { kind: "posix", path: cwd }; +} + +function armDialogAutoResponder( + runtime: SupervisorRuntime, + threadId: string, + kind: string, +): () => void { + const responders = (KIND_DIALOG_RESPONDERS[kind] ?? []).map((r) => ({ ...r, fired: false })); + if (responders.length === 0) return () => undefined; + const state = { stopped: false }; + void (async () => { + while (!state.stopped) { + const text = decodeScrollbackText(runtime.readTerminalScrollback(threadId)); + for (const r of responders) { + if (!r.fired && r.needle.test(text)) { + r.fired = true; + try { + await runtime.writeTerminal({ threadId, data: r.response }); + // eslint-disable-next-line no-console + console.log(`[int-test] auto-respond → ${r.reason}`); + } catch { + // PTY may have closed; ignore. + } + } + } + if (responders.every((r) => r.fired)) return; + await sleep(500); + } + })(); + return () => { + state.stopped = true; + }; +} + +async function waitForSessionRef( + runtime: SupervisorRuntime, + events: SupervisorEvent[], + threadId: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const event of events) { + if ( + event.type === "thread-state" && + event.threadId === threadId && + event.sessionRef?.providerSessionId + ) { + return event.sessionRef; + } + if ( + event.type === "thread-state" && + event.threadId === threadId && + event.status === "error" + ) { + throw new Error( + `Thread ${threadId} entered error state: ${event.errorMessage ?? "(no message)"}`, + ); + } + } + // Also check the live snapshot — some adapters surface sessionRef via the + // CLI hook channel without ever emitting a populated thread-state event + // before we poll. + const snapshot = runtime.getThreadSnapshots().find((s) => s.threadId === threadId); + if (snapshot?.sessionRef?.providerSessionId) { + return snapshot.sessionRef; + } + await sleep(500); + } + const tail = runtime.readTerminalScrollback(threadId).slice(-600).replace(/\s+/g, " ").trim(); + const recentThreadStates = events + .filter((e) => e.type === "thread-state" && (e as { threadId?: string }).threadId === threadId) + .slice(-5) + .map((e) => { + const ev = e as { + status?: string; + attention?: string; + sessionRef?: { providerSessionId?: string }; + errorMessage?: string; + }; + return { + status: ev.status, + attention: ev.attention, + sessionId: ev.sessionRef?.providerSessionId, + errorMessage: ev.errorMessage, + }; + }); + const eventTypes = [...new Set(events.map((e) => e.type))]; + throw new Error( + `Timed out waiting for sessionRef on thread ${threadId} after ${timeoutMs}ms. ` + + `Scrollback tail: ${tail || "(empty)"} ` + + `Recent thread-state events: ${JSON.stringify(recentThreadStates)} ` + + `Emitted event types: ${eventTypes.join(",")}`, + ); +} + +async function waitForTurnComplete( + runtime: SupervisorRuntime, + threadId: string, + promptToken: string, + timeoutMs: number, +): Promise { + // Providers only flush their conversation file to disk after the turn + // settles. The supervisor's thread-state isn't a reliable cross-provider + // signal (some adapters emit launching→idle before the LLM has even + // responded), so we use output quiescence instead: wait until the prompt + // is visible in scrollback and the PTY has produced no new bytes for + // QUIET_MS. That holds for every CLI in the registry because they all + // stream tokens through the PTY and stop writing once the turn settles. + const QUIET_MS = 4000; + const deadline = Date.now() + timeoutMs; + let lastLen = -1; + let lastChangeAt = Date.now(); + let promptSeen = false; + while (Date.now() < deadline) { + const scrollback = runtime.readTerminalScrollback(threadId); + if (!promptSeen) { + promptSeen = scrollback.includes(promptToken); + if (promptSeen) { + lastLen = scrollback.length; + lastChangeAt = Date.now(); + } + } else if (scrollback.length !== lastLen) { + lastLen = scrollback.length; + lastChangeAt = Date.now(); + } else if (Date.now() - lastChangeAt >= QUIET_MS) { + return; + } + await sleep(500); + } + throw new Error( + `Timed out after ${timeoutMs}ms waiting for turn to settle on thread ${threadId} ` + + `(promptSeen=${promptSeen})`, + ); +} + +async function waitForScrollbackMatch( + runtime: SupervisorRuntime, + threadId: string, + needle: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastScrollback = ""; + while (Date.now() < deadline) { + const scrollback = runtime.readTerminalScrollback(threadId); + lastScrollback = scrollback; + if (scrollback.includes(needle)) { + return scrollback; + } + await sleep(500); + } + // Surface a snippet of the last-seen scrollback to make failures debuggable. + const tail = lastScrollback.slice(-400).replace(/\s+/g, " ").trim(); + throw new Error( + `Timed out after ${timeoutMs}ms waiting for "${needle}" in scrollback for thread ${threadId}. ` + + `Tail: ${tail || "(empty)"}`, + ); +} + +interface SuiteContext { + runtime: SupervisorRuntime; + events: SupervisorEvent[]; + cwd: string; + dataDir: string; + prevDataDir: string | undefined; + adapters: AgentAdapter[]; +} + +const ctx: SuiteContext = { + runtime: undefined as unknown as SupervisorRuntime, + events: [], + cwd: "", + dataDir: "", + prevDataDir: process.env.LIGHTCODE_DATA_DIR, + adapters: [], +}; + +beforeAll(() => { + // Use the actual repo root as the project cwd so providers that gate launch + // on directory trust (Claude's "Do you trust this folder?" dialog, Cursor's + // workspace prompt, etc.) don't block on a never-seen tmp path. The test + // prompt is "reply OK" — providers do not write files for that — so using + // the repo dir is non-destructive. Supervisor state stays isolated via + // LIGHTCODE_DATA_DIR (set per test in beforeEach). + ctx.cwd = process.cwd(); + ctx.adapters = createAgentRegistry(); +}); + +// Providers that need CLI hook plugins active to surface sessionRef back into +// the runtime. OpenCode's structured-session→terminal handoff drops the +// session id when hooks are disabled, so we leave hooks enabled for it. +// Codex must stay on `disableCliHookPlugin: true` — its first-run "Hooks +// need review" dialog triggers off the installed hook bundle. +const NEEDS_HOOKS_ENABLED = new Set(["opencode"]); + +beforeEach((testCtx) => { + // Fresh supervisor + data dir per test row. Real CLI processes leave + // lingering state (PTY handles, session files, hook plugin installs) that + // can poison later providers when the same runtime is reused. Isolating + // each test costs ~1s of construction but eliminates order-dependent + // flakiness in the full sweep. + ctx.dataDir = mkdtempSync(join(tmpdir(), "lightcode-int-")); + process.env.LIGHTCODE_DATA_DIR = ctx.dataDir; + const kind = testCtx.task.name; + const disableHooks = !NEEDS_HOOKS_ENABLED.has(kind); + writeFileSync( + join(ctx.dataDir, "settings.json"), + JSON.stringify({ disableCliHookPlugin: disableHooks }), + ); + ctx.events = []; + ctx.runtime = new SupervisorRuntime((event) => { + ctx.events.push(event); + }); +}); + +afterEach(() => { + try { + ctx.runtime?.dispose(); + } catch { + // best-effort + } + if (ctx.prevDataDir === undefined) { + delete process.env.LIGHTCODE_DATA_DIR; + } else { + process.env.LIGHTCODE_DATA_DIR = ctx.prevDataDir; + } + // Only remove the data dir — `ctx.cwd` is the lightcode repo root and must + // never be deleted. + if (ctx.dataDir) rmSync(ctx.dataDir, { recursive: true, force: true }); +}); + +const REGISTRY_KINDS = createAgentRegistry().map((a) => a.kind); + +describe("provider lifecycle: create → unload → resume → initial message visible", () => { + for (const kind of REGISTRY_KINDS) { + it(`${kind}`, async (testCtx) => { + const adapter = ctx.adapters.find((a) => a.kind === kind); + if (!adapter) { + testCtx.skip(`adapter ${kind} not in registry`); + return; + } + + if (!adapter.capabilities.supportsResume) { + testCtx.skip(`${kind}: adapter does not support resume`); + return; + } + + const status = await adapter.detectInstall(); + if (!status.installed) { + testCtx.skip(`${kind}: CLI not installed`); + return; + } + if (status.authState !== "authenticated") { + testCtx.skip(`${kind}: authState=${status.authState} (need "authenticated")`); + return; + } + + const model = pickCheapModel(adapter, status); + if (!model) { + testCtx.skip(`${kind}: no model available in capabilities`); + return; + } + // eslint-disable-next-line no-console + console.log(`[int-test] ${kind} using model: ${model}`); + + const threadId = `int-${kind}-${randomUUID()}`; + const projectLocation = makeProjectLocation(ctx.cwd); + const config: ThreadConfig = { model }; + const startPayload: StartThreadPayload = { + threadId, + projectLocation, + agentKind: kind, + config, + prompt: PROMPT, + initialSize: { cols: 132, rows: 40 }, + // Force PTY/TUI even for adapters that expose `presentationModes: + // ["terminal", "gui"]` so we exercise the real terminal scrollback + // path. Without this, future drift in any adapter's default could + // silently push the test into structured/ACP mode. + presentationMode: "terminal", + }; + + let resumeStarted = false; + const stopResponder = armDialogAutoResponder(ctx.runtime, threadId, kind); + try { + await ctx.runtime.startThread(startPayload); + const sessionRef = await waitForSessionRef( + ctx.runtime, + ctx.events, + threadId, + SESSION_REF_TIMEOUT_MS, + ); + + // Wait for the first turn to settle so the provider has persisted + // the conversation to disk. Without this Claude (and most CLI + // providers) reject resume with "no conversation found". + await waitForTurnComplete(ctx.runtime, threadId, PROMPT_TOKEN, TURN_COMPLETE_TIMEOUT_MS); + + // Unload — close the live thread, leaving the discovered sessionRef + // as the resume handle. + await ctx.runtime.closeThread({ threadId }); + await sleep(750); + + // Resume — same threadId, supply the sessionRef so the supervisor's + // restart path uses the adapter's `buildResumeArgv`. + const resumePayload: StartThreadPayload = { + threadId, + projectLocation, + agentKind: kind, + config, + prompt: "", + initialSize: { cols: 132, rows: 40 }, + sessionRef, + presentationMode: "terminal", + }; + resumeStarted = true; + await ctx.runtime.startThread(resumePayload); + + // After resume, the provider CLI normally reprints prior conversation + // history into the PTY. Assert the original prompt's token is back + // in scrollback. + await waitForScrollbackMatch( + ctx.runtime, + threadId, + PROMPT_TOKEN, + SCROLLBACK_WAIT_TIMEOUT_MS, + ); + + const scrollback = ctx.runtime.readTerminalScrollback(threadId); + expect(scrollback).toContain(PROMPT_TOKEN); + } finally { + stopResponder(); + try { + await ctx.runtime.closeThread({ threadId }); + } catch { + // best-effort + } + // Guard against orphaned PTYs if the resume path threw before close. + if (!resumeStarted) { + await sleep(100); + } + } + }); + } +}); diff --git a/tsconfig.json b/tsconfig.json index 55b98ad5..1aa3f69d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,12 @@ "useDefineForClassFields": true, "types": ["node", "vite/client"] }, - "include": ["src", "vite.config.ts", "tsdown.config.ts", "vitest.config.ts"] + "include": [ + "src", + "tests", + "vite.config.ts", + "tsdown.config.ts", + "vitest.config.ts", + "vitest.integration.config.ts" + ] } diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 00000000..0aff77cb --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,27 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +// Standalone config for live-CLI integration tests under `tests/integration/`. +// Kept out of the default `vitest run` glob so `pnpm test` stays fast and +// hermetic. Invoke explicitly via `pnpm test:integration:providers`. +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + test: { + name: "integration", + environment: "node", + include: ["tests/integration/**/*.test.ts"], + // Each provider spins up a real CLI process, waits for session + // discovery, then resumes. Five minutes per test gives slower providers + // (Antigravity, OpenCode) headroom on a cold machine. + testTimeout: 300_000, + hookTimeout: 60_000, + // Run sequentially — concurrent real CLIs would compete for terminal + // resources, auth tokens, and (most importantly) provider rate limits. + fileParallelism: false, + sequence: { concurrent: false }, + }, +});