From 9425be3b97560d9e1929cc5cb16e32a717667d28 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Fri, 24 Jul 2026 09:34:40 +0000 Subject: [PATCH 001/122] ci(Devcontainer): update --- .devcontainer/Dockerfile | 16 ++++++++++ .devcontainer/devcontainer-lock.json | 14 +++++++++ .devcontainer/devcontainer.json | 45 ++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..639446c0 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/devcontainers/go:1-1.24-bookworm + +# The base image ships a yarn apt repo with an expired GPG key that breaks +# `apt-get update`; drop it before installing anything. +RUN rm -f /etc/apt/sources.list.d/yarn.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends shellcheck \ + && rm -rf /var/lib/apt/lists/* + +# The image pins GOTOOLCHAIN=local but go.mod requires a newer patch release; +# let Go download the exact toolchain version on demand. +ENV GOTOOLCHAIN=auto + +# proxy.golang.org is DNS-hijacked on this network (HiNet safebrowsing) and +# goproxy.io lacks toolchain modules; goproxy.cn mirrors both. +ENV GOPROXY=https://goproxy.cn,direct diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..098c8d64 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + }, + "ghcr.io/shyim/devcontainers-features/bun:0": { + "version": "0.0.1", + "resolved": "ghcr.io/shyim/devcontainers-features/bun@sha256:689eae681aa08981175829a59953ba67a7d311f6a05c15d1bbbcb2da2839827e", + "integrity": "sha256:689eae681aa08981175829a59953ba67a7d311f6a05c15d1bbbcb2da2839827e" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..88b4bc31 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,45 @@ +{ + "name": "agentapi", + // Dockerfile removes the base image's expired yarn apt repo and installs + // shellcheck (required by `make lint/shellcheck`) + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + // Bun builds the chat UI (see Makefile: `bun run build`, `bun lint`) + "ghcr.io/shyim/devcontainers-features/bun:0": {}, + // Node is still needed by Next.js/ESLint tooling under the hood + "ghcr.io/devcontainers/features/node:1": { + "version": "lts" + } + }, + "remoteUser": "root", + "containerUser": "root", + // safe.directory: running as root, git refuses the host-owned repo without it + "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && cd chat && bun install", + "forwardPorts": [3284, 3000, 6006, 2345], + "portsAttributes": { + "3284": { + "label": "agentapi server" + }, + "3000": { + "label": "chat UI (next dev)" + }, + "6006": { + "label": "storybook" + }, + "2345": { + "label": "delve debugger" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "golang.go", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss" + ] + } + } +} From fbabdb7534017aa9713ce25c99bfe4928fcaec9d Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Fri, 24 Jul 2026 10:32:33 +0000 Subject: [PATCH 002/122] feat(chat): refine agent workspace UI --- chat/src/app/embed/page.tsx | 4 +- chat/src/app/globals.css | 13 + chat/src/app/header.tsx | 78 +++-- chat/src/app/layout.tsx | 4 +- chat/src/app/page.tsx | 4 +- chat/src/components/chat-provider.tsx | 23 +- chat/src/components/chat.tsx | 12 +- chat/src/components/drag-drop.tsx | 8 +- chat/src/components/message-input.tsx | 64 ++-- chat/src/components/message-list.tsx | 423 ++++++++++++++++---------- chat/src/components/mode-toggle.tsx | 2 +- 11 files changed, 414 insertions(+), 221 deletions(-) diff --git a/chat/src/app/embed/page.tsx b/chat/src/app/embed/page.tsx index 768acdd1..8fb97509 100644 --- a/chat/src/app/embed/page.tsx +++ b/chat/src/app/embed/page.tsx @@ -10,9 +10,9 @@ export default function EmbedPage() { } > -
+
-
+
); diff --git a/chat/src/app/globals.css b/chat/src/app/globals.css index b628ac37..a2271e31 100644 --- a/chat/src/app/globals.css +++ b/chat/src/app/globals.css @@ -76,6 +76,7 @@ --sidebar-accent-foreground: oklch(0.208 0.042 265.755); --sidebar-border: oklch(0.929 0.013 255.508); --sidebar-ring: oklch(0.704 0.04 256.788); + --surface-glow: oklch(0.93 0.025 250 / 0.75); } .dark { @@ -110,6 +111,7 @@ --sidebar-accent-foreground: oklch(0.984 0.003 247.858); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.551 0.027 264.364); + --surface-glow: oklch(0.24 0.025 260 / 0.7); } @layer base { @@ -118,5 +120,16 @@ } body { @apply bg-background text-foreground; + min-width: 320px; + text-rendering: optimizeLegibility; + } + + button, + [role="button"] { + -webkit-tap-highlight-color: transparent; + } + + ::selection { + @apply bg-primary/15; } } diff --git a/chat/src/app/header.tsx b/chat/src/app/header.tsx index 47b370fe..7c48a7f2 100644 --- a/chat/src/app/header.tsx +++ b/chat/src/app/header.tsx @@ -1,36 +1,72 @@ "use client"; -import {AgentType, useChat} from "@/components/chat-provider"; -import {ModeToggle} from "@/components/mode-toggle"; +import { AgentType, useChat } from "@/components/chat-provider"; +import { ModeToggle } from "@/components/mode-toggle"; +import { Activity, Bot, CircleAlert, CircleCheck, LoaderCircle, WifiOff } from "lucide-react"; export function Header() { - const {serverStatus, agentType} = useChat(); + const { serverStatus, agentType } = useChat(); - return ( -
- AgentAPI Chat + const status = { + stable: { + label: "Ready", + detail: "Agent is ready", + icon: CircleCheck, + className: "text-emerald-600 dark:text-emerald-400", + }, + running: { + label: "Working", + detail: "Agent is processing", + icon: LoaderCircle, + className: "text-amber-600 dark:text-amber-400", + }, + offline: { + label: "Offline", + detail: "Reconnecting to server", + icon: WifiOff, + className: "text-destructive", + }, + unknown: { + label: "Connecting", + detail: "Waiting for agent status", + icon: CircleAlert, + className: "text-muted-foreground", + }, + }[serverStatus]; + const StatusIcon = status.icon; -
- {serverStatus !== "unknown" && ( -
- - Status: - {serverStatus} + return ( +
+
+
+ +
+
+
+ AgentAPI + + Live session +
- )} +

Remote coding agent workspace

+
+
+
{agentType !== "unknown" && ( -
+
+ {AgentType[agentType].displayName}
)} - +
+ + {status.label} +
+
); diff --git a/chat/src/app/layout.tsx b/chat/src/app/layout.tsx index 7c44c440..4b098b77 100644 --- a/chat/src/app/layout.tsx +++ b/chat/src/app/layout.tsx @@ -10,8 +10,8 @@ const geistSans = Geist({ }); export const metadata: Metadata = { - title: "AgentAPI Chat", - description: "A ChatGPT-like interface for AgentAPI", + title: "AgentAPI — Live Agent Session", + description: "Chat with and control your remote coding agent.", }; export default function RootLayout({ diff --git a/chat/src/app/page.tsx b/chat/src/app/page.tsx index 1530885d..7aeaf11a 100644 --- a/chat/src/app/page.tsx +++ b/chat/src/app/page.tsx @@ -11,10 +11,10 @@ export default function Home() { } > -
+
-
+
); diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 34e04363..bbc3f241 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -145,19 +145,21 @@ export function ChatProvider({ children }: PropsWithChildren) { const [serverStatus, setServerStatus] = useState("unknown"); const [agentType, setAgentType] = useState("custom"); const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); const agentAPIUrl = useAgentAPIUrl(); // Set up SSE connection to the events endpoint useEffect(() => { + let disposed = false; + // Function to create and set up EventSource const setupEventSource = () => { + if (disposed) return null; + if (eventSourceRef.current) { eventSourceRef.current.close(); } - // Reset messages when establishing a new connection - setMessages([]); - if (!agentAPIUrl) { console.warn( "agentAPIUrl is not set, SSE connection cannot be established." @@ -251,10 +253,13 @@ export function ChatProvider({ children }: PropsWithChildren) { eventSource.onerror = (error) => { console.error("EventSource error:", error); setServerStatus("offline"); + eventSource.close(); - // Try to reconnect after delay - setTimeout(() => { - if (eventSourceRef.current) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + reconnectTimeoutRef.current = setTimeout(() => { + if (!disposed) { setupEventSource(); } }, 3000); @@ -268,10 +273,16 @@ export function ChatProvider({ children }: PropsWithChildren) { // Clean up on component unmount return () => { + disposed = true; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } if (eventSource) { // Check if eventSource was successfully created eventSource.close(); } + eventSourceRef.current = null; }; }, [agentAPIUrl]); diff --git a/chat/src/components/chat.tsx b/chat/src/components/chat.tsx index be1a720d..96d94695 100644 --- a/chat/src/components/chat.tsx +++ b/chat/src/components/chat.tsx @@ -5,16 +5,20 @@ import MessageInput from "./message-input"; import MessageList from "./message-list"; export function Chat() { - const {messages, loading, sendMessage, serverStatus} = useChat(); + const { messages, loading, sendMessage, serverStatus, agentType } = useChat(); return ( - <> - +
+ - +
); } diff --git a/chat/src/components/drag-drop.tsx b/chat/src/components/drag-drop.tsx index 0ffd88cf..3765272c 100644 --- a/chat/src/components/drag-drop.tsx +++ b/chat/src/components/drag-drop.tsx @@ -22,16 +22,16 @@ export function DragDrop({ onFilesAdded, disabled = false, children, className =
{isDragActive && !disabled && ( -
-

Drop the files here

+
+

Drop files to attach

)} {children}
); -} \ No newline at end of file +} diff --git a/chat/src/components/message-input.tsx b/chat/src/components/message-input.tsx index 5e889154..ccd6850a 100644 --- a/chat/src/components/message-input.tsx +++ b/chat/src/components/message-input.tsx @@ -12,6 +12,9 @@ import { SendIcon, Upload, Square, + Keyboard, + MessageSquareText, + Paperclip, } from "lucide-react"; import {Tabs, TabsList, TabsTrigger} from "./ui/tabs"; import type {ServerStatus} from "./chat-provider"; @@ -56,7 +59,7 @@ export default function MessageInput({ serverStatus, }: MessageInputProps) { const [message, setMessage] = useState(""); - const [inputMode, setInputMode] = useState("text"); + const [inputMode, setInputMode] = useState<"text" | "control">("text"); const [sentChars, setSentChars] = useState([]); const textareaRef = useRef(null); const nextCharId = useRef(0); @@ -202,8 +205,12 @@ export default function MessageInput({ }; return ( - -
+ setInputMode(value as "text" | "control")} + className="shrink-0 border-t bg-background/85 backdrop-blur-xl" + > +
-
+
{inputMode === "control" && !disabled ? ( @@ -228,7 +237,7 @@ export default function MessageInput({ onKeyDown={handleKeyDown as any} onFocus={() => setControlAreaFocused(true)} onBlur={() => setControlAreaFocused(false)} - className="cursor-text p-4 h-20 text-muted-foreground flex items-center justify-center w-full outline-none text-sm" + className="flex h-24 w-full cursor-text items-center justify-center p-4 text-center text-sm text-muted-foreground outline-none focus:bg-muted/35" > {controlAreaFocused ? "Press any key to send to terminal (arrows, Ctrl+C, Ctrl+R, etc.)" @@ -246,41 +255,46 @@ export default function MessageInput({ ? "Running..." : "Type a message..." } - className="resize-none w-full text-sm outline-none p-4 h-20 max-h-[400px]" + className="min-h-20 max-h-[400px] w-full resize-none bg-transparent px-4 pb-2 pt-4 text-sm leading-6 outline-none sm:px-5" disabled={serverStatus !== "stable"} /> )}
-
- +
+ { textareaRef.current?.focus(); }} > - Text + + Chat { textareaRef.current?.focus(); }} > + Control -
+
{serverStatus !== "running" && } @@ -290,7 +304,7 @@ export default function MessageInput({ type="submit" disabled={disabled || !message.trim()} size="icon" - className="rounded-full" + className="rounded-full shadow-sm" title={"Send Message"} > @@ -301,7 +315,9 @@ export default function MessageInput({ {inputMode === "text" && serverStatus === "running" && (
); diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index dc2f913b..91897108 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -1,6 +1,24 @@ "use client"; -import React, {useLayoutEffect, useRef, useEffect, useCallback, useMemo, useState} from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ArrowDown, + Check, + Clipboard, + Code2, + Sparkles, + TerminalSquare, + User, +} from "lucide-react"; +import { Button } from "./ui/button"; +import type { AgentType, ServerStatus } from "./chat-provider"; interface Message { role: string; @@ -8,211 +26,304 @@ interface Message { id: number; } -// Draft messages are used to optmistically update the UI -// before the server responds. interface DraftMessage extends Omit { id?: number; } interface MessageListProps { messages: (Message | DraftMessage)[]; + serverStatus: ServerStatus; + agentType: AgentType; } -interface ProcessedMessageProps { - messageContent: string; - index: number; -} - -export default function MessageList({messages}: MessageListProps) { - const [scrollAreaRef, setScrollAreaRef] = useState(null); - - // Track if user is at bottom - default to true for initial scroll +export default function MessageList({ + messages, + serverStatus, + agentType, +}: MessageListProps) { + const [scrollArea, setScrollArea] = useState(null); + const [showScrollButton, setShowScrollButton] = useState(false); const isAtBottomRef = useRef(true); - // Track the last known scroll height to detect new content const lastScrollHeightRef = useRef(0); - // Track if we're currently doing a programmatic scroll - const isProgrammaticScrollRef = useRef(false); - - const checkIfAtBottom = useCallback(() => { - if (!scrollAreaRef) return false; - const { scrollTop, scrollHeight, clientHeight } = scrollAreaRef; - return scrollTop + clientHeight >= scrollHeight - 10; // 10px tolerance - }, [scrollAreaRef]); - - // Track Ctrl (Windows/Linux) or Cmd (Mac) key state - // This is so that underline is only visible when hover + cmd/ctrl - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey || e.metaKey) document.documentElement.classList.add('modifier-pressed'); - }; - const handleKeyUp = (e: KeyboardEvent) => { - if (!e.ctrlKey && !e.metaKey) document.documentElement.classList.remove('modifier-pressed'); - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - document.documentElement.classList.remove('modifier-pressed'); - }; - }, []); + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = "smooth") => { + scrollArea?.scrollTo({ top: scrollArea.scrollHeight, behavior }); + isAtBottomRef.current = true; + setShowScrollButton(false); + }, + [scrollArea], + ); - // Update isAtBottom on scroll useEffect(() => { - if (!scrollAreaRef) return; + if (!scrollArea) return; const handleScroll = () => { - if (isProgrammaticScrollRef.current) return; - isAtBottomRef.current = checkIfAtBottom(); + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + const atBottom = scrollTop + clientHeight >= scrollHeight - 32; + isAtBottomRef.current = atBottom; + setShowScrollButton(!atBottom); }; - // Initial check handleScroll(); + scrollArea.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollArea.removeEventListener("scroll", handleScroll); + }, [scrollArea]); - scrollAreaRef.addEventListener("scroll", handleScroll); - scrollAreaRef.addEventListener("scrollend", () => isProgrammaticScrollRef.current = false); - return () => { - scrollAreaRef.removeEventListener("scroll", handleScroll) - scrollAreaRef.removeEventListener("scrollend", () => isProgrammaticScrollRef.current = false); - - }; - }, [checkIfAtBottom, scrollAreaRef]); - - // Handle auto-scrolling when messages change useLayoutEffect(() => { - if (!scrollAreaRef) return; + if (!scrollArea) return; - const currentScrollHeight = scrollAreaRef.scrollHeight; - - // Check if this is new content (scroll height increased) - const hasNewContent = currentScrollHeight > lastScrollHeightRef.current; + const currentHeight = scrollArea.scrollHeight; + const hasNewContent = currentHeight > lastScrollHeightRef.current; const isFirstRender = lastScrollHeightRef.current === 0; - const isNewUserMessage = - messages.length > 0 && messages[messages.length - 1].role === "user"; + const isNewUserMessage = messages.at(-1)?.role === "user"; - // Auto-scroll only if: - // 1. It's the first render, OR - // 2. There's new content AND user was at the bottom, OR - // 3. The user sent a new message if ( hasNewContent && (isFirstRender || isAtBottomRef.current || isNewUserMessage) ) { - isProgrammaticScrollRef.current = true; - scrollAreaRef.scrollTo({ - top: currentScrollHeight, - behavior: isFirstRender ? "instant" : "smooth", - }); - // After scrolling, we're at the bottom - isAtBottomRef.current = true; + scrollToBottom(isFirstRender ? "auto" : "smooth"); } + lastScrollHeightRef.current = currentHeight; + }, [messages, scrollArea, scrollToBottom]); - // Update the last known scroll height - lastScrollHeightRef.current = currentScrollHeight; - }, [messages, scrollAreaRef]); + return ( +
+
+ {messages.length === 0 ? ( + + ) : ( +
+ {messages.map((message, index) => ( + + ))} +
+ )} +
- // If no messages, show a placeholder - if (messages.length === 0) { - return ( -
-

No messages yet. Start the conversation!

+ {showScrollButton && ( + + )} +
+ ); +} + +function EmptyState({ + serverStatus, + agentType, +}: { + serverStatus: ServerStatus; + agentType: AgentType; +}) { + const isOffline = serverStatus === "offline"; + const name = + agentType === "unknown" ? "your coding agent" : agentType.replace("-", " "); + + return ( +
+
+
+
+ +
+

+ + Live agent workspace +

+

+ {isOffline ? "The agent server is offline" : `Start working with ${name}`} +

+

+ {isOffline + ? "AgentAPI is trying to reconnect. Check the server URL and make sure the agent process is running." + : "Send a task, attach project files, or switch to Control mode when the terminal needs direct input."} +

+ {!isOffline && ( +
+ + +
+ )} +
+ ); +} + +function Hint({ + icon: Icon, + text, +}: { + icon: React.ComponentType<{ className?: string }>; + text: string; +}) { + return ( +
+ + {text} +
+ ); +} + +function MessageItem({ + message, + index, +}: { + message: Message | DraftMessage; + index: number; +}) { + const isUser = message.role === "user"; + const isDraft = message.id === undefined; + + if (!isUser) { + return ( +
+
+
+ + Agent output + {isDraft && ( + Updating… + )} +
+ {message.content && } +
+ {message.content === "" ? ( + + ) : ( + + )} +
); } return ( -
+
- {messages.map((message, index) => ( -
-
-
- {message.role !== "user" && message.content === "" ? ( - - ) : ( - - )} -
-
-
- ))} + className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border bg-foreground text-background shadow-xs" + > +
-
+
+
+ You + {isDraft && Sending…} +
+
+ {message.content === "" ? ( + + ) : ( + + )} +
+
+ + ); +} + +function CopyButton({ content }: { content: string }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(content); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }; + + return ( + ); } const LoadingDots = () => ( -
-
); } + +function SessionDetail({ + icon: Icon, + label, + value, + valueClassName = "", +}: { + icon: ComponentType<{className?: string}>; + label: string; + value: string; + valueClassName?: string; +}) { + return ( +
+ + {label} + + {value} + +
+ ); +} From ad3cf034bd53e88bc5486fed8067ca96ed459bc1 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sat, 25 Jul 2026 15:01:19 +0000 Subject: [PATCH 014/122] fix(chat): improve mobile viewport behavior --- chat/src/app/globals.css | 1 + chat/src/app/layout.tsx | 8 +++++++- chat/src/components/mode-toggle.tsx | 7 ++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/chat/src/app/globals.css b/chat/src/app/globals.css index a2271e31..98461ac1 100644 --- a/chat/src/app/globals.css +++ b/chat/src/app/globals.css @@ -121,6 +121,7 @@ body { @apply bg-background text-foreground; min-width: 320px; + overscroll-behavior: none; text-rendering: optimizeLegibility; } diff --git a/chat/src/app/layout.tsx b/chat/src/app/layout.tsx index 4b098b77..02440514 100644 --- a/chat/src/app/layout.tsx +++ b/chat/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist } from "next/font/google"; import "./globals.css"; import { Toaster } from "@/components/ui/sonner"; @@ -14,6 +14,12 @@ export const metadata: Metadata = { description: "Chat with and control your remote coding agent.", }; +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", +}; + export default function RootLayout({ children, }: Readonly<{ diff --git a/chat/src/components/mode-toggle.tsx b/chat/src/components/mode-toggle.tsx index 641bc858..9fc942de 100644 --- a/chat/src/components/mode-toggle.tsx +++ b/chat/src/components/mode-toggle.tsx @@ -18,7 +18,12 @@ export function ModeToggle() { return ( - + )} + + + ))} +

+ Maximum file size: 10 MB per file. +

+ + )} + {inputMode === "text" && queuedMessages.length > 0 && (
- Queued messages · {queuedMessages.length} + Queued tasks · {queuedMessages.length} Show Hide @@ -572,7 +828,7 @@ export default function MessageInput({ } }} className="h-6 w-56 min-w-0 bg-transparent text-xs outline-none" - aria-label={`Edit queued message ${index + 1}`} + aria-label={`Edit queued task ${index + 1}`} /> ) : ( {queuedMessage.content} @@ -593,15 +849,15 @@ export default function MessageInput({ } title={ editingQueuedIndex === index - ? "Save queued message" - : "Edit queued message" + ? "Save queued task" + : "Edit queued task" } > {editingQueuedIndex === index ? : } - {editingQueuedIndex === index ? "Save" : "Edit"} queued message + {editingQueuedIndex === index ? "Save" : "Edit"} queued task ))} @@ -663,7 +919,7 @@ export default function MessageInput({ handleUploadClick()} - disabled={disabled || serverStatus === "running"} + disabled={disabled} className="min-h-10" > @@ -685,13 +941,19 @@ export default function MessageInput({ (serverStatus === "stable" || serverStatus === "running") && ( + + )} { + dismissFailedMessage(clientId); + setSuggestedPrompt(content); + }} + onDismissMessage={dismissFailedMessage} + onRunTask={(content) => void sendMessage(content, "user")} + onStopTask={() => void sendMessage("\x1b", "raw")} /> { - id?: number; -} +import {taskMatchesQuery, taskToMarkdown} from "@/lib/task-actions"; +import {groupConsecutiveTools} from "@/lib/activity-groups"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; interface MessageListProps { messages: (Message | DraftMessage)[]; @@ -44,6 +58,11 @@ interface MessageListProps { serverStatus: ServerStatus; agentType: AgentType; onSelectPrompt?: (prompt: string) => void; + onRetryMessage: (clientId: string) => Promise; + onEditMessage: (clientId: string, content: string) => void; + onDismissMessage: (clientId: string) => void; + onRunTask: (content: string) => void; + onStopTask: () => void; } interface ToolCall { @@ -51,6 +70,7 @@ interface ToolCall { name: string; input?: unknown; result?: string; + status?: "running" | "completed" | "failed"; isError?: boolean; timestamp: string; } @@ -60,9 +80,23 @@ interface TaskSection { prompt: Message | DraftMessage; responses: (Message | DraftMessage)[]; toolCalls: ToolCall[]; + richActivity: TaskActivity[]; } +type TaskActivity = + | { + type: "message"; + key: string; + message: Message | DraftMessage; + } + | { + type: "tool"; + key: string; + toolCall: ToolCall; + }; + type TaskStatus = "queued" | "running" | "completed" | "failed"; +type TaskFilter = "all" | TaskStatus | "tool-error"; function getTaskStatus( task: TaskSection, @@ -70,7 +104,15 @@ function getTaskStatus( taskCount: number, serverStatus: ServerStatus, ): TaskStatus { - if (task.prompt.id === undefined) return serverStatus === "running" ? "queued" : "running"; + if ( + task.prompt.id === undefined && + (task.prompt as DraftMessage).deliveryStatus === "failed" + ) { + return "failed"; + } + if (task.prompt.id === undefined) { + return serverStatus === "running" ? "queued" : "running"; + } if (task.toolCalls.some((tool) => tool.isError)) return "failed"; if (index === taskCount - 1 && serverStatus === "running") return "running"; return "completed"; @@ -82,6 +124,11 @@ export default function MessageList({ serverStatus, agentType, onSelectPrompt, + onRetryMessage, + onEditMessage, + onDismissMessage, + onRunTask, + onStopTask, }: MessageListProps) { const [scrollArea, setScrollArea] = useState(null); const [showScrollButton, setShowScrollButton] = useState(false); @@ -89,6 +136,11 @@ export default function MessageList({ const [canScrollToPreviousUser, setCanScrollToPreviousUser] = useState(false); const [canScrollToNextUser, setCanScrollToNextUser] = useState(false); + const [showAllTasks, setShowAllTasks] = useState(false); + const [taskQuery, setTaskQuery] = useState(""); + const [taskFilter, setTaskFilter] = useState("all"); + const [currentSearchResult, setCurrentSearchResult] = useState(0); + const searchInputRef = useRef(null); const isAtBottomRef = useRef(true); const lastScrollHeightRef = useRef(0); const userMessageCount = messages.filter( @@ -106,6 +158,7 @@ export default function MessageList({ prompt: message, responses: [], toolCalls: [], + richActivity: [], }); } else if (tasks.length > 0) { tasks.at(-1)!.responses.push(message); @@ -115,16 +168,86 @@ export default function MessageList({ } for (const toolCall of toolCalls) { - const toolTime = Date.parse(toolCall.timestamp); - const target = [...tasks].reverse().find((task) => { - if (!task.prompt.time) return false; - return Date.parse(task.prompt.time) <= toolTime; + const target = findTaskAtTime(tasks, toolCall.timestamp); + target?.toolCalls.push(toolCall); + } + + const callsByID = new Map(toolCalls.map((toolCall) => [toolCall.id, toolCall])); + for (const message of richMessages) { + if (message.role !== "assistant") continue; + const target = findTaskAtTime(tasks, message.timestamp); + if (!target) continue; + + message.content.forEach((block, index) => { + if (block.type === "text" && block.text) { + target.richActivity.push({ + type: "message", + key: `rich-message-${message.message_id}-${index}`, + message: { + id: -1, + role: "assistant", + content: block.text, + time: message.timestamp, + }, + }); + } + if (block.type === "tool_use" && block.tool_use_id) { + const toolCall = callsByID.get(block.tool_use_id); + if (toolCall) { + target.richActivity.push({ + type: "tool", + key: `rich-tool-${block.tool_use_id}`, + toolCall, + }); + } + } }); - (target ?? tasks.at(-1))?.toolCalls.push(toolCall); } return {prelude, tasks}; - }, [messages, toolCalls]); + }, [messages, richMessages, toolCalls]); + const filteredTasks = useMemo( + () => + timeline.tasks + .map((task, index) => ({ + task, + index, + status: getTaskStatus( + task, + index, + timeline.tasks.length, + serverStatus, + ), + matchCount: countTaskMatches(task, taskQuery), + })) + .filter(({task, status, matchCount}) => { + const matchesFilter = + taskFilter === "all" || + (taskFilter === "tool-error" + ? task.toolCalls.some((tool) => tool.isError) + : status === taskFilter); + return ( + matchesFilter && + (taskQuery.trim() === "" || + matchCount > 0 || + taskMatchesQuery(toSearchableTask(task), taskQuery)) + ); + }), + [serverStatus, taskFilter, taskQuery, timeline.tasks], + ); + const filtersActive = taskQuery.trim() !== "" || taskFilter !== "all"; + const totalMatchCount = filteredTasks.reduce( + (total, task) => total + task.matchCount, + 0, + ); + const hiddenTaskCount = + !filtersActive && !showAllTasks && filteredTasks.length > 8 + ? filteredTasks.length - 8 + : 0; + const visibleTasks = + hiddenTaskCount > 0 + ? filteredTasks.slice(hiddenTaskCount) + : filteredTasks; const contentSignature = useMemo( () => [ @@ -140,6 +263,43 @@ export default function MessageList({ [timeline], ); + useEffect(() => { + setCurrentSearchResult(0); + }, [taskFilter, taskQuery]); + + const navigateSearchResults = useCallback( + (direction: -1 | 1) => { + if (!scrollArea || filteredTasks.length === 0) return; + const next = + (currentSearchResult + direction + filteredTasks.length) % + filteredTasks.length; + setCurrentSearchResult(next); + scrollArea + .querySelector(`[data-search-result="${next}"]`) + ?.scrollIntoView({behavior: "smooth", block: "center"}); + }, + [currentSearchResult, filteredTasks.length, scrollArea], + ); + + useEffect(() => { + const handleGlobalSearchShortcut = (event: globalThis.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } else if ( + event.key === "Escape" && + document.activeElement === searchInputRef.current + ) { + setTaskQuery(""); + searchInputRef.current?.blur(); + } + }; + window.addEventListener("keydown", handleGlobalSearchShortcut); + return () => + window.removeEventListener("keydown", handleGlobalSearchShortcut); + }, []); + const scrollToBottom = useCallback( (behavior: ScrollBehavior = "smooth") => { scrollArea?.scrollTo({ top: scrollArea.scrollHeight, behavior }); @@ -220,19 +380,136 @@ export default function MessageList({ onSelectPrompt={onSelectPrompt} /> ) : ( + <> +
+
+ + + + {taskQuery + ? `${filteredTasks.length} tasks · ${totalMatchCount} matches` + : `${filteredTasks.length} of ${timeline.tasks.length}`} + + {taskQuery && filteredTasks.length > 0 && ( +
+ + +
+ )} +
+
{timeline.prelude.map((message, index) => ( ))} - {timeline.tasks.map((task, index) => ( + {hiddenTaskCount > 0 && ( + + )} + {visibleTasks.length === 0 && ( +
+ No tasks match the current search and filter. +
+ )} + {visibleTasks.map(({task, index, status}) => { + const searchResultIndex = filteredTasks.findIndex( + (entry) => entry.task.key === task.key, + ); + return ( onSelectPrompt?.(content)} + onFollowUp={(content) => + onSelectPrompt?.(`Follow up on this task:\n\n${content}\n\n`) + } + onStopTask={onStopTask} + searchQuery={taskQuery} + searchResultIndex={searchResultIndex} + isCurrentSearchResult={ + Boolean(taskQuery) && + searchResultIndex === currentSearchResult + } /> - ))} + ); + })}
+ )} @@ -326,6 +603,7 @@ function collectToolCalls(richMessages: RichMessage[]): ToolCall[] { id: block.tool_use_id, name: block.tool_name || "Tool", input: block.tool_input, + status: block.status || "running", timestamp: message.timestamp, }); } @@ -337,7 +615,8 @@ function collectToolCalls(richMessages: RichMessage[]): ToolCall[] { name: existing?.name || "Tool", input: existing?.input, result: block.text ?? "", - isError: block.is_error, + status: block.status || (block.is_error ? "failed" : "completed"), + isError: block.status === "failed" || block.is_error, timestamp: existing?.timestamp || message.timestamp, }); } @@ -347,6 +626,28 @@ function collectToolCalls(richMessages: RichMessage[]): ToolCall[] { return [...calls.values()]; } +function findTaskAtTime(tasks: TaskSection[], timestamp: string) { + const targetTime = Date.parse(timestamp); + if (Number.isNaN(targetTime)) return undefined; + + let low = 0; + let high = tasks.length - 1; + let match: TaskSection | undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const promptTime = tasks[middle].prompt.time + ? Date.parse(tasks[middle].prompt.time!) + : Number.NaN; + if (Number.isNaN(promptTime) || promptTime > targetTime) { + high = middle - 1; + } else { + match = tasks[middle]; + low = middle + 1; + } + } + return match; +} + function formatToolInput(input: unknown): string { if (input === undefined || input === null) return ""; if (typeof input === "string") { @@ -364,12 +665,32 @@ function formatToolInput(input: unknown): string { } } -function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { - const isPending = toolCall.result === undefined; +function ToolCallCard({ + toolCall, + searchQuery = "", +}: { + toolCall: ToolCall; + searchQuery?: string; +}) { + const isFailed = toolCall.status === "failed" || Boolean(toolCall.isError); + const isPending = + toolCall.status === "running" || + (toolCall.status === undefined && toolCall.result === undefined); const input = formatToolInput(toolCall.input); + const [isOpen, setIsOpen] = useState( + Boolean(searchQuery) || isPending || isFailed, + ); + + useEffect(() => { + if (searchQuery || isPending || isFailed) setIsOpen(true); + }, [isFailed, isPending, searchQuery]); return ( -
+
setIsOpen(event.currentTarget.open)} + > @@ -379,14 +700,14 @@ function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { {toolCall.name} - {toolCall.isError + {isFailed ? "Tool call failed" : isPending ? "Tool call is running" : "Tool call completed"} - {toolCall.isError ? ( + {isFailed ? ( ) : isPending ? ( @@ -396,11 +717,14 @@ function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
- {input && } + {input && ( + + )} {toolCall.result !== undefined && ( )} {!input && toolCall.result === undefined && ( @@ -413,47 +737,72 @@ function ToolCallCard({ toolCall }: { toolCall: ToolCall }) { ); } -function ToolActivityGroup({toolCalls}: {toolCalls: ToolCall[]}) { +function ToolCallGroup({ + toolCalls, + searchQuery, +}: { + toolCalls: ToolCall[]; + searchQuery: string; +}) { const pending = toolCalls.filter((tool) => tool.result === undefined).length; const failed = toolCalls.filter((tool) => tool.isError).length; + const [isOpen, setIsOpen] = useState( + Boolean(searchQuery) || pending > 0 || failed > 0, + ); + + useEffect(() => { + if (searchQuery || pending > 0 || failed > 0) setIsOpen(true); + }, [failed, pending, searchQuery]); return ( -
- - - +
setIsOpen(event.currentTarget.open)} + > + + + + Tool activity · {toolCalls.length} - - - Tool activity · {toolCalls.length} - - - {failed > 0 - ? `${failed} failed` - : pending > 0 - ? `${pending} running` - : "All tool calls completed"} - + + {failed > 0 + ? `${failed} failed` + : pending > 0 + ? `${pending} running` + : "Completed"} - + -
+
{toolCalls.map((toolCall) => ( - + ))}
); } -function ToolDetail({ label, content }: { label: string; content: string }) { +function ToolDetail({ + label, + content, + searchQuery, +}: { + label: string; + content: string; + searchQuery: string; +}) { return (

{label}

-        {content}
+        
       
); @@ -463,10 +812,30 @@ function TaskGroup({ task, number, status, + onRetryMessage, + onEditMessage, + onDismissMessage, + onRunTask, + onEditTask, + onFollowUp, + onStopTask, + searchQuery, + searchResultIndex, + isCurrentSearchResult, }: { task: TaskSection; number: number; status: TaskStatus; + onRetryMessage: (clientId: string) => Promise; + onEditMessage: (clientId: string, content: string) => void; + onDismissMessage: (clientId: string) => void; + onRunTask: (content: string) => void; + onEditTask: (content: string) => void; + onFollowUp: (content: string) => void; + onStopTask: () => void; + searchQuery: string; + searchResultIndex: number; + isCurrentSearchResult: boolean; }) { const statusMeta = { queued: { @@ -491,39 +860,279 @@ function TaskGroup({ }, }[status]; const StatusIcon = statusMeta.icon; + const activity = getTaskActivity(task); + const groupedActivity = groupConsecutiveTools(activity); + const markdown = taskToMarkdown(toSearchableTask(task), number); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const latestTool = [...task.toolCalls].reverse().find( + (tool) => tool.result === undefined, + ) ?? task.toolCalls.at(-1); + + useEffect(() => { + if (status !== "running") { + setElapsedSeconds(0); + return; + } + const startedAt = task.prompt.time + ? Date.parse(task.prompt.time) + : Date.now(); + const update = () => + setElapsedSeconds( + Math.max(0, Math.floor((Date.now() - startedAt) / 1000)), + ); + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); + }, [status, task.prompt.time]); + + const copyTask = async () => { + try { + await navigator.clipboard.writeText(markdown); + toast.success("Task copied"); + } catch { + toast.error("Could not copy the task"); + } + }; + + const exportTask = () => { + const url = URL.createObjectURL( + new Blob([markdown], {type: "text/markdown;charset=utf-8"}), + ); + const link = document.createElement("a"); + link.href = url; + link.download = `agentapi-task-${number}.md`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + }; return ( -
-
- - Task {number} - - - - {statusMeta.label} - +
+
+
+ + Task {number} + + {status === "running" && ( +

+ {latestTool ? `Using ${latestTool.name}` : "Processing task"} + {" · "} + {formatElapsedTime(elapsedSeconds)} +

+ )} +
+
+ + + {statusMeta.label} + + + + + + + onRunTask(task.prompt.content)} + > + + Run again + + onEditTask(task.prompt.content)} + > + + Edit and resend + + onFollowUp(task.prompt.content)} + > + + Create follow-up + + void copyTask()}> + + Copy task and output + + + + Export Markdown + + + + {status === "running" && ( + + )} +
-
- - {task.toolCalls.length > 0 && ( - +
+ + {groupedActivity.map((item) => + item.type === "message" ? ( + + ) : item.type === "tool-group" ? ( + + ) : ( +
+ +
+ ), )} - {task.responses.map((message, index) => ( - - ))}
); } +function getTaskActivity(task: TaskSection): TaskActivity[] { + if (task.richActivity.some((item) => item.type === "message")) { + return task.richActivity; + } + + return [ + ...task.responses.map((message, index) => ({ + type: "message" as const, + key: `response-${message.id ?? index}`, + message, + })), + ...task.toolCalls.map((toolCall) => ({ + type: "tool" as const, + key: `tool-${toolCall.id}`, + toolCall, + })), + ].sort((left, right) => { + const leftTime = + left.type === "message" ? left.message.time : left.toolCall.timestamp; + const rightTime = + right.type === "message" ? right.message.time : right.toolCall.timestamp; + if (!leftTime) return 1; + if (!rightTime) return -1; + return Date.parse(leftTime) - Date.parse(rightTime); + }); +} + +function toSearchableTask(task: TaskSection) { + const activity = getTaskActivity(task); + return { + prompt: task.prompt.content, + responses: activity + .filter( + (item): item is Extract => + item.type === "message", + ) + .map((item) => item.message.content), + tools: task.toolCalls.map((tool) => ({ + name: tool.name, + input: formatToolInput(tool.input), + result: tool.result, + isError: tool.isError, + })), + }; +} + +function countTaskMatches(task: TaskSection, query: string) { + const normalized = query.trim().toLocaleLowerCase(); + if (!normalized) return 0; + const searchable = toSearchableTask(task); + return [ + searchable.prompt, + ...searchable.responses, + ...searchable.tools.flatMap((tool) => [ + tool.name, + tool.input ?? "", + tool.result ?? "", + ]), + ].reduce( + (total, content) => + total + content.toLocaleLowerCase().split(normalized).length - 1, + 0, + ); +} + +function HighlightedText({ + content, + query, +}: { + content: string; + query: string; +}) { + const normalized = query.trim(); + if (!normalized) return content; + const expression = new RegExp( + `(${normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, + "giu", + ); + return content.split(expression).map((part, index) => + part.toLocaleLowerCase() === normalized.toLocaleLowerCase() ? ( + + {part} + + ) : ( + {part} + ), + ); +} + +function formatElapsedTime(seconds: number) { + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + function EmptyState({ serverStatus, agentType, @@ -555,7 +1164,7 @@ function EmptyState({

{isOffline ? "AgentAPI is trying to reconnect. Check the server URL and make sure the agent process is running." - : "Send a task, attach project files, or switch to Control mode when the terminal needs direct input."} + : "Send a task, attach project files, or switch to Terminal input when the agent needs direct keystrokes."}

{!isOffline && (
@@ -602,16 +1211,36 @@ function PromptHint({ function MessageItem({ message, + onRetryMessage, + onEditMessage, + onDismissMessage, + searchQuery: globalSearchQuery = "", }: { message: Message | DraftMessage; + onRetryMessage?: (clientId: string) => Promise; + onEditMessage?: (clientId: string, content: string) => void; + onDismissMessage?: (clientId: string) => void; + searchQuery?: string; }) { const isUser = message.role === "user"; const isDraft = message.id === undefined; + const draft = isDraft ? (message as DraftMessage) : undefined; + const isFailed = draft?.deliveryStatus === "failed"; + const [outputMode, setOutputMode] = useState<"raw" | "rendered">("raw"); + const [searchOpen, setSearchOpen] = useState(false); + const [outputSearchQuery, setOutputSearchQuery] = useState(""); + const effectiveSearchQuery = outputSearchQuery || globalSearchQuery; + const matchCount = + outputSearchQuery.trim() === "" + ? 0 + : message.content + .toLocaleLowerCase() + .split(outputSearchQuery.trim().toLocaleLowerCase()).length - 1; if (!isUser) { return (
-
+
Agent output @@ -628,15 +1257,80 @@ function MessageItem({ Updating… )}
- {message.content && } + {message.content && ( +
+ + + +
+ )}
+ {searchOpen && ( + + )} {message.content === "" ? ( ) : ( - +
+ +
)}
); @@ -653,18 +1347,30 @@ function MessageItem({
-
- You - {message.time && ( - +
+
+ You + {message.time && ( + + )} + {isDraft && !isFailed && ( + Sending… + )} + {isFailed && ( + + Not sent + + )} +
+ {message.content && ( + )} - {isDraft && Sending…}
{message.content === "" ? ( @@ -673,15 +1379,58 @@ function MessageItem({ )}
+ {isFailed && draft && ( +
+ + + +
+ )}
); } -function CopyButton({ content }: { content: string }) { +function CopyButton({ + content, + label = "response", +}: { + content: string; + label?: "task" | "response"; +}) { const [copied, setCopied] = useState(false); const copy = async () => { @@ -692,7 +1441,7 @@ function CopyButton({ content }: { content: string }) { } catch { // Clipboard access can be blocked in embedded or non-secure contexts. setCopied(false); - toast.error("Could not copy the response", { + toast.error(`Could not copy the ${label}`, { description: "Clipboard access may be blocked in this browser context.", }); } @@ -703,10 +1452,10 @@ function CopyButton({ content }: { content: string }) { type="button" onClick={copy} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground sm:size-9" - title="Copy response" + title={copied ? `Copied ${label}` : `Copy ${label}`} + aria-label={copied ? `Copied ${label}` : `Copy ${label}`} > {copied ? : } - Copy response ); } diff --git a/chat/src/lib/activity-groups.test.ts b/chat/src/lib/activity-groups.test.ts new file mode 100644 index 00000000..ee68710c --- /dev/null +++ b/chat/src/lib/activity-groups.test.ts @@ -0,0 +1,22 @@ +import {describe, expect, test} from "bun:test"; +import {groupConsecutiveTools} from "./activity-groups"; + +describe("groupConsecutiveTools", () => { + test("groups only adjacent tool calls", () => { + const activity = [ + {type: "tool" as const, key: "a", toolCall: {id: "a"}}, + {type: "tool" as const, key: "b", toolCall: {id: "b"}}, + {type: "message" as const, key: "m", message: "done"}, + {type: "tool" as const, key: "c", toolCall: {id: "c"}}, + ]; + const grouped = groupConsecutiveTools(activity); + expect(grouped).toHaveLength(3); + expect(grouped[0]).toEqual({ + type: "tool-group", + key: "tool-group-a-b", + toolCalls: [{id: "a"}, {id: "b"}], + }); + expect(grouped[1].type).toBe("message"); + expect(grouped[2].type).toBe("tool"); + }); +}); diff --git a/chat/src/lib/activity-groups.ts b/chat/src/lib/activity-groups.ts new file mode 100644 index 00000000..1ce2fb02 --- /dev/null +++ b/chat/src/lib/activity-groups.ts @@ -0,0 +1,41 @@ +export type GroupableActivity = + | {type: "message"; key: string; message: TMessage} + | {type: "tool"; key: string; toolCall: TTool}; + +export type GroupedActivity = + | GroupableActivity + | {type: "tool-group"; key: string; toolCalls: TTool[]}; + +export function groupConsecutiveTools( + activity: GroupableActivity[], +): GroupedActivity[] { + const grouped: GroupedActivity[] = []; + for (let index = 0; index < activity.length; index += 1) { + const item = activity[index]; + if (item.type !== "tool") { + grouped.push(item); + continue; + } + + const tools = [item.toolCall]; + while (activity[index + 1]?.type === "tool") { + index += 1; + tools.push( + (activity[index] as Extract< + GroupableActivity, + {type: "tool"} + >).toolCall, + ); + } + grouped.push( + tools.length === 1 + ? item + : { + type: "tool-group", + key: `tool-group-${tools[0].id}-${tools.at(-1)!.id}`, + toolCalls: tools, + }, + ); + } + return grouped; +} diff --git a/chat/src/lib/task-actions.test.ts b/chat/src/lib/task-actions.test.ts new file mode 100644 index 00000000..277ed827 --- /dev/null +++ b/chat/src/lib/task-actions.test.ts @@ -0,0 +1,27 @@ +import {describe, expect, test} from "bun:test"; +import {taskMatchesQuery, taskToMarkdown} from "./task-actions"; + +const task = { + prompt: "Fix the upload flow", + responses: ["Implemented progress feedback."], + tools: [ + {name: "test", input: '{"suite":"upload"}', result: "all passed"}, + ], +}; + +describe("task actions", () => { + test("searches prompts, responses, and tool activity", () => { + expect(taskMatchesQuery(task, "UPLOAD")).toBe(true); + expect(taskMatchesQuery(task, "progress")).toBe(true); + expect(taskMatchesQuery(task, "all passed")).toBe(true); + expect(taskMatchesQuery(task, "unrelated")).toBe(false); + }); + + test("exports a complete Markdown task", () => { + const markdown = taskToMarkdown(task, 3); + expect(markdown).toContain("# Task 3"); + expect(markdown).toContain("## Prompt"); + expect(markdown).toContain("## Agent output"); + expect(markdown).toContain("## Tool: test"); + }); +}); diff --git a/chat/src/lib/task-actions.ts b/chat/src/lib/task-actions.ts new file mode 100644 index 00000000..e78cb4a8 --- /dev/null +++ b/chat/src/lib/task-actions.ts @@ -0,0 +1,37 @@ +export interface SearchableTask { + prompt: string; + responses: string[]; + tools: Array<{name: string; input?: string; result?: string; isError?: boolean}>; +} + +export function taskMatchesQuery(task: SearchableTask, query: string) { + const normalized = query.trim().toLocaleLowerCase(); + if (!normalized) return true; + return [ + task.prompt, + ...task.responses, + ...task.tools.flatMap((tool) => [ + tool.name, + tool.input ?? "", + tool.result ?? "", + ]), + ].some((content) => content.toLocaleLowerCase().includes(normalized)); +} + +export function taskToMarkdown(task: SearchableTask, number: number) { + const sections = [`# Task ${number}`, "", "## Prompt", "", task.prompt]; + if (task.responses.length > 0) { + sections.push("", "## Agent output", "", ...task.responses); + } + for (const tool of task.tools) { + sections.push( + "", + `## Tool: ${tool.name}${tool.isError ? " (failed)" : ""}`, + ); + if (tool.input) sections.push("", "### Input", "", "```json", tool.input, "```"); + if (tool.result !== undefined) { + sections.push("", "### Result", "", "```text", tool.result, "```"); + } + } + return `${sections.join("\n")}\n`; +} From fd3001a92ab14002b8c79fc741ca4bb14a507d3c Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 07:21:15 +0000 Subject: [PATCH 020/122] fix(termexec): keep emulator cursor in sync with wide characters The vt10x emulator advances the cursor one column per rune, but TUI agents lay out CJK and other double-width runes as two columns and reposition with absolute escapes, leaving spurious gaps mid-line. Inject a one-column padding rune after each wide rune printed in ground state and strip it from ReadScreen output. Co-Authored-By: Claude --- go.mod | 2 +- lib/termexec/termexec.go | 16 ++++- lib/termexec/widechar.go | 129 ++++++++++++++++++++++++++++++++++ lib/termexec/widechar_test.go | 120 +++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 lib/termexec/widechar.go create mode 100644 lib/termexec/widechar_test.go diff --git a/go.mod b/go.mod index e43f5ea3..564c8fca 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/danielgtaylor/huma/v2 v2.32.0 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 + github.com/mattn/go-runewidth v0.0.16 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.11.1 @@ -225,7 +226,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/lib/termexec/termexec.go b/lib/termexec/termexec.go index e83c63d7..739839c9 100644 --- a/lib/termexec/termexec.go +++ b/lib/termexec/termexec.go @@ -82,6 +82,7 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error // Warning: This depends on xpty internals and may break if xpty changes. // A proper fix would require forking xpty or getting upstream changes. pp := util.GetUnexportedField(xp, "pp").(*xpty.PassthroughPipe) + injector := &wideCharInjector{} for { r, _, err := pp.ReadRune() if err != nil { @@ -97,6 +98,12 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error // writing to the terminal updates its state. without it, // xp.State will always return an empty string xp.Term.WriteRune(r) + if injector.shouldPad(r) { + // Keep the emulator's cursor in sync with the two-column + // layout the application assumes for wide runes. The + // padding is stripped in ReadScreen. See widechar.go. + xp.Term.WriteRune(widePadRune) + } process.lastScreenUpdate = clock.Now() process.screenUpdateLock.Unlock() } @@ -105,6 +112,11 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error return process, nil } +// Pid returns the OS process ID of the child process. +func (p *Process) Pid() int { + return p.execCmd.Process.Pid +} + func (p *Process) Signal(sig os.Signal) error { return p.execCmd.Process.Signal(sig) } @@ -124,14 +136,14 @@ func (p *Process) ReadScreen() string { if p.clock.Since(p.lastScreenUpdate) >= 16*time.Millisecond { state := p.xp.State.String() p.screenUpdateLock.RUnlock() - return state + return stripWidePadding(state) } p.screenUpdateLock.RUnlock() t := p.clock.NewTimer(16 * time.Millisecond) <-t.C t.Stop() } - return p.xp.State.String() + return stripWidePadding(p.xp.State.String()) } // Write sends input to the process via the pseudo terminal. diff --git a/lib/termexec/widechar.go b/lib/termexec/widechar.go new file mode 100644 index 00000000..bfba8f47 --- /dev/null +++ b/lib/termexec/widechar.go @@ -0,0 +1,129 @@ +package termexec + +import ( + "strings" + + "github.com/mattn/go-runewidth" +) + +// widePadRune is a private-use rune injected into the terminal emulator +// after every double-width rune, and stripped from ReadScreen output. +// +// The vt10x emulator advances the cursor by exactly one column per rune, +// but terminal applications (e.g. Claude Code) lay out text treating +// double-width runes (CJK, some symbols) as occupying two columns and +// reposition the cursor with absolute escape sequences. The mismatch +// makes repositioned writes land too far right, leaving spurious gaps in +// the middle of lines. Injecting a one-column padding rune after each +// double-width rune keeps the emulator's cursor in sync with the +// application's column math, mirroring how real terminals dedicate two +// cells to a wide glyph. +const widePadRune = '\uE000' + +// escState tracks vt10x's escape sequence parser states. The injector +// mirrors the state machine in vt10x's parse.go so that padding is only +// injected for runes that are actually printed to the grid (ground +// state), never for bytes that are part of an escape sequence. +type escState int + +const ( + escGround escState = iota + escEsc // after ESC + escCSI // after ESC [ + escStr // OSC/DCS/APC/PM string payload + escStrEnd // after ESC inside a string payload + escOneChar // ESC # or ESC ( : consumes exactly one more rune +) + +// wideCharInjector decides whether a rune written to the terminal should +// be followed by a widePadRune. +type wideCharInjector struct { + state escState + csiLen int +} + +// shouldPad advances the parser state with r and reports whether r was a +// double-width rune printed in ground state. +func (w *wideCharInjector) shouldPad(r rune) bool { + switch w.state { + case escGround: + if isControlCode(r) { + w.handleControlCode(r) + return false + } + return runewidth.RuneWidth(r) == 2 + + case escEsc: + if isControlCode(r) { + w.handleControlCode(r) + return false + } + switch r { + case '[': + w.state = escCSI + w.csiLen = 0 + case 'P', '_', '^', ']', 'k': + w.state = escStr + case '#', '(': + w.state = escOneChar + default: + // Includes ')', '*', '+' which vt10x treats as complete + // sequences (the designator char is processed in ground + // state), and all single-char sequences (D, E, M, 7, 8, ...). + w.state = escGround + } + + case escCSI: + if isControlCode(r) { + w.handleControlCode(r) + return false + } + w.csiLen++ + if (r >= 0x40 && r <= 0x7E) || w.csiLen >= 256 { + w.state = escGround + } + + case escStr: + switch r { + case '\033': + w.state = escStrEnd + case '\a': + w.state = escGround + } + + case escStrEnd: + if isControlCode(r) { + w.handleControlCode(r) + return false + } + w.state = escGround + + case escOneChar: + if isControlCode(r) { + w.handleControlCode(r) + return false + } + w.state = escGround + } + return false +} + +// handleControlCode mirrors vt10x's handleControlCodes: ESC switches to +// the escape state from any state where control codes are interpreted; +// all other control codes leave the parser state unchanged. +func (w *wideCharInjector) handleControlCode(r rune) { + if r == '\033' { + w.state = escEsc + } +} + +// isControlCode matches vt10x's definition. +func isControlCode(r rune) bool { + return r < 0x20 || r == 0177 +} + +// stripWidePadding removes the injected padding runes from a screen +// snapshot before it's handed to consumers. +func stripWidePadding(screen string) string { + return strings.ReplaceAll(screen, string(widePadRune), "") +} diff --git a/lib/termexec/widechar_test.go b/lib/termexec/widechar_test.go new file mode 100644 index 00000000..12e045f4 --- /dev/null +++ b/lib/termexec/widechar_test.go @@ -0,0 +1,120 @@ +package termexec + +import ( + "strings" + "testing" + + "github.com/ActiveState/vt10x" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type nopRWC struct{} + +func (nopRWC) Read(p []byte) (int, error) { return 0, nil } +func (nopRWC) Write(p []byte) (int, error) { return len(p), nil } +func (nopRWC) Close() error { return nil } + +// newTestTerm creates a vt10x terminal and a feed function that mirrors +// the termexec read loop: every rune is written to the terminal and +// followed by widePadRune when the injector asks for it. +func newTestTerm(t *testing.T, cols, rows int) (*vt10x.State, func(string)) { + t.Helper() + var state vt10x.State + term, err := vt10x.Create(&state, nopRWC{}) + require.NoError(t, err) + term.Resize(cols, rows) + + injector := &wideCharInjector{} + feed := func(s string) { + for _, r := range s { + term.WriteRune(r) + if injector.shouldPad(r) { + term.WriteRune(widePadRune) + } + } + } + return &state, feed +} + +func firstLine(state *vt10x.State) string { + screen := stripWidePadding(state.String()) + return strings.TrimRight(strings.Split(screen, "\n")[0], " ") +} + +func TestWideCharInjector_AbsolutePositioningAfterCJK(t *testing.T) { + state, feed := newTestTerm(t, 80, 24) + + // The application writes 4 CJK runes (8 display columns) and then + // positions the cursor at column 9 (1-based) to continue the line, + // as a wide-aware renderer would. + feed("你好世界") + feed("\033[1;9H") + feed("X") + + assert.Equal(t, "你好世界X", firstLine(state)) +} + +func TestWideCharInjector_WithoutPaddingWouldMisalign(t *testing.T) { + // Sanity check documenting the vt10x behavior this fix works around: + // without padding, the same sequence leaves a 4-cell gap. + var state vt10x.State + term, err := vt10x.Create(&state, nopRWC{}) + require.NoError(t, err) + term.Resize(80, 24) + for _, r := range "你好世界\033[1;9HX" { + term.WriteRune(r) + } + line := strings.TrimRight(strings.Split(state.String(), "\n")[0], " ") + assert.Equal(t, "你好世界 X", line) +} + +func TestWideCharInjector_LinearMixedTextUnchanged(t *testing.T) { + state, feed := newTestTerm(t, 80, 24) + + feed("中文 and ascii 混合 text") + + assert.Equal(t, "中文 and ascii 混合 text", firstLine(state)) +} + +func TestWideCharInjector_NoPaddingInsideEscapeSequences(t *testing.T) { + state, feed := newTestTerm(t, 80, 24) + + // Wide runes inside an OSC title string must not trigger padding. + feed("\033]0;标题\a") + feed("你") + feed("\033[1;3H") + feed("!") + + assert.Equal(t, "你!", firstLine(state)) +} + +func TestWideCharInjector_CSIWithControlCodesInside(t *testing.T) { + state, feed := newTestTerm(t, 80, 24) + + // SGR sequences interleaved with CJK text. + feed("\033[1m你\033[0m好") + feed("\033[1;5H") + feed("末") + + assert.Equal(t, "你好末", firstLine(state)) +} + +func TestWideCharInjector_CarriageReturnOverwrite(t *testing.T) { + state, feed := newTestTerm(t, 80, 24) + + // Overwrite a wide line from the start; leftover padding from the + // old content must not corrupt the result. + feed("狀態更新中") + feed("\r") + feed("完成!") + feed("\033[1;7H") + feed("ok") + + assert.Equal(t, "完成!ok中", firstLine(state)) +} + +func TestStripWidePadding(t *testing.T) { + assert.Equal(t, "你好", stripWidePadding("你好")) + assert.Equal(t, "plain", stripWidePadding("plain")) +} From 35ad9c567cc096dbdb4ca2e0b6f6a90c9f459430 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 07:21:21 +0000 Subject: [PATCH 021/122] fix(screentracker): trim previous turn output leaked by TUI re-renders TUI agents sometimes re-render already-finalized transcript lines (e.g. Codex redraws previous cells when starting a new turn), which pushes the screen diff boundary above the previous turn's output and leaks its tail into the head of the new agent message. Detect the line-level overlap between the previous turn's message tail and the new message head and remove it. Co-Authored-By: Claude --- lib/screentracker/diff.go | 58 +++++++++++++++++++++++++ lib/screentracker/diff_internal_test.go | 46 ++++++++++++++++++++ lib/screentracker/pty_conversation.go | 29 +++++++++++++ 3 files changed, 133 insertions(+) diff --git a/lib/screentracker/diff.go b/lib/screentracker/diff.go index 47c5b78c..ffece548 100644 --- a/lib/screentracker/diff.go +++ b/lib/screentracker/diff.go @@ -54,3 +54,61 @@ func screenDiff(oldScreen, newScreen string, agentType msgfmt.AgentType) string } return strings.Join(newSectionLines[startLine:endLine+1], "\n") } + +// trimPreviousMessageOverlap removes leading lines of newMsg that exactly +// duplicate the trailing lines of prevMsg. +// +// screenDiff detects new content as everything below the first line of the +// screen that wasn't present in the baseline snapshot. TUI agents sometimes +// re-render already-finalized transcript lines (e.g. Codex redraws previous +// cells when starting a new turn), which makes the first mismatching line +// land above the previous turn's output and the previous message's tail +// leaks into the head of the new message. An agent legitimately starting a +// new response with an exact line-by-line copy of its previous message's +// trailing lines is practically impossible with terminal wrapping, so the +// overlap is treated as a diff artifact and removed. +// +// Lines are compared with trailing whitespace removed (terminal snapshots +// pad lines to the screen width). Overlaps consisting only of whitespace +// lines are ignored. +func trimPreviousMessageOverlap(prevMsg, newMsg string) string { + if prevMsg == "" || newMsg == "" { + return newMsg + } + prevLines := strings.Split(prevMsg, "\n") + newLines := strings.Split(newMsg, "\n") + norm := func(s string) string { + return strings.TrimRight(s, " \t") + } + + overlap := 0 + maxOverlap := min(len(prevLines), len(newLines)) + for n := maxOverlap; n > 0; n-- { + match := true + hasContent := false + for i := 0; i < n; i++ { + line := norm(prevLines[len(prevLines)-n+i]) + if line != norm(newLines[i]) { + match = false + break + } + if line != "" { + hasContent = true + } + } + if match && hasContent { + overlap = n + break + } + } + if overlap == 0 { + return newMsg + } + + remaining := newLines[overlap:] + // Drop whitespace-only lines left at the top after the trim. + for len(remaining) > 0 && strings.TrimSpace(remaining[0]) == "" { + remaining = remaining[1:] + } + return strings.Join(remaining, "\n") +} diff --git a/lib/screentracker/diff_internal_test.go b/lib/screentracker/diff_internal_test.go index d68bc36c..23b7c0e5 100644 --- a/lib/screentracker/diff_internal_test.go +++ b/lib/screentracker/diff_internal_test.go @@ -37,3 +37,49 @@ func TestScreenDiff(t *testing.T) { }) } } + +func TestTrimPreviousMessageOverlap(t *testing.T) { + t.Run("no overlap", func(t *testing.T) { + assert.Equal(t, "new content", trimPreviousMessageOverlap("previous message", "new content")) + }) + t.Run("empty inputs", func(t *testing.T) { + assert.Equal(t, "new", trimPreviousMessageOverlap("", "new")) + assert.Equal(t, "", trimPreviousMessageOverlap("prev", "")) + }) + t.Run("full previous tail leaks into new head", func(t *testing.T) { + prev := "• first answer line\n second answer line" + newMsg := " second answer line\n\n› next question\n\n• new answer" + assert.Equal(t, "› next question\n\n• new answer", trimPreviousMessageOverlap(prev, newMsg)) + }) + t.Run("multi line overlap", func(t *testing.T) { + prev := "a\nb\nc\nd" + newMsg := "c\nd\nnew line" + assert.Equal(t, "new line", trimPreviousMessageOverlap(prev, newMsg)) + }) + t.Run("trailing whitespace is ignored in comparison", func(t *testing.T) { + prev := "line one \nline two " + newMsg := "line two\nfresh" + assert.Equal(t, "fresh", trimPreviousMessageOverlap(prev, newMsg)) + }) + t.Run("whitespace only overlap is not trimmed", func(t *testing.T) { + prev := "content\n " + newMsg := " \ndifferent" + assert.Equal(t, " \ndifferent", trimPreviousMessageOverlap(prev, newMsg)) + }) + t.Run("entire new message is an overlap", func(t *testing.T) { + prev := "a\nb\nc" + newMsg := "b\nc" + assert.Equal(t, "", trimPreviousMessageOverlap(prev, newMsg)) + }) + t.Run("largest overlap wins", func(t *testing.T) { + // prev tail "x\ny\nx\ny" vs new head: use the longest match + prev := "x\ny\nx\ny" + newMsg := "x\ny\nx\ny\nnew" + assert.Equal(t, "new", trimPreviousMessageOverlap(prev, newMsg)) + }) + t.Run("partial line match does not count", func(t *testing.T) { + prev := "the quick brown fox" + newMsg := "the quick brown\nfox jumps" + assert.Equal(t, newMsg, trimPreviousMessageOverlap(prev, newMsg)) + }) +} diff --git a/lib/screentracker/pty_conversation.go b/lib/screentracker/pty_conversation.go index 452c6ae9..dbdc511f 100644 --- a/lib/screentracker/pty_conversation.go +++ b/lib/screentracker/pty_conversation.go @@ -318,6 +318,25 @@ func (c *PTYConversation) lastMessage(role ConversationRole) ConversationMessage return ConversationMessage{} } +// previousTurnAgentMessageLocked returns the last agent message from before +// the last user message, i.e. the finalized message of the previous turn. +// Returns a zero value if there is no such message. Caller MUST hold c.lock. +func (c *PTYConversation) previousTurnAgentMessageLocked() ConversationMessage { + lastUserIdx := -1 + for i := len(c.messages) - 1; i >= 0; i-- { + if c.messages[i].Role == ConversationRoleUser { + lastUserIdx = i + break + } + } + for i := lastUserIdx - 1; i >= 0; i-- { + if c.messages[i].Role == ConversationRoleAgent { + return c.messages[i] + } + } + return ConversationMessage{} +} + // caller MUST hold c.lock func (c *PTYConversation) updateLastAgentMessageLocked(screen string, timestamp time.Time) { if c.writingMessage { @@ -329,13 +348,23 @@ func (c *PTYConversation) updateLastAgentMessageLocked(screen string, timestamp if c.cfg.FormatMessage != nil { agentMessage = c.cfg.FormatMessage(agentMessage, lastUserMessage.Message) } + restoredFromState := false if c.loadStateStatus == LoadStateSucceeded && !c.userSentMessageAfterLoadState && len(c.messages) > 0 && c.messages[len(c.messages)-1].Role == ConversationRoleAgent { agentMessage = c.messages[len(c.messages)-1].Message + restoredFromState = true } if c.cfg.FormatToolCall != nil { agentMessage, toolCalls = c.cfg.FormatToolCall(agentMessage) } + // Guard against TUI re-renders leaking the previous turn's output into + // the current turn's message (see trimPreviousMessageOverlap). Skip for + // messages restored verbatim from persisted state. + if !restoredFromState { + if prev := c.previousTurnAgentMessageLocked(); prev.Message != "" { + agentMessage = trimPreviousMessageOverlap(prev.Message, agentMessage) + } + } for _, toolCall := range toolCalls { if c.toolCallMessageSet[toolCall] == false { c.toolCallMessageSet[toolCall] = true From 51599677a3227826415bbc5f01c49883a3a94445 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 07:21:27 +0000 Subject: [PATCH 022/122] feat(jsonlwatcher): add agent session JSONL watcher Tail the agent's session JSONL log (Claude Code, Codex) as a sidecar alongside the PTY. Agent-specific resolvers locate the session file from the agent PID; line parsers assemble rich structured messages (text, thinking, tool calls, tool results, model and usage data) and normalized session events. The watcher keeps no state: completed messages and raw lines are handed to callbacks for downstream storage and deduplication. Co-Authored-By: Claude --- lib/jsonlwatcher/claude_parser.go | 205 ++++++++++ lib/jsonlwatcher/claude_resolver.go | 63 +++ lib/jsonlwatcher/codex_parser.go | 373 ++++++++++++++++++ lib/jsonlwatcher/codex_parser_test.go | 381 +++++++++++++++++++ lib/jsonlwatcher/codex_resolver.go | 122 ++++++ lib/jsonlwatcher/codex_resolver_test.go | 140 +++++++ lib/jsonlwatcher/session_events.go | 214 +++++++++++ lib/jsonlwatcher/session_events_test.go | 98 +++++ lib/jsonlwatcher/testdata/codex_sample.jsonl | 16 + lib/jsonlwatcher/testdata/sample.jsonl | 8 + lib/jsonlwatcher/types.go | 115 ++++++ lib/jsonlwatcher/watcher.go | 182 +++++++++ lib/jsonlwatcher/watcher_test.go | 352 +++++++++++++++++ 13 files changed, 2269 insertions(+) create mode 100644 lib/jsonlwatcher/claude_parser.go create mode 100644 lib/jsonlwatcher/claude_resolver.go create mode 100644 lib/jsonlwatcher/codex_parser.go create mode 100644 lib/jsonlwatcher/codex_parser_test.go create mode 100644 lib/jsonlwatcher/codex_resolver.go create mode 100644 lib/jsonlwatcher/codex_resolver_test.go create mode 100644 lib/jsonlwatcher/session_events.go create mode 100644 lib/jsonlwatcher/session_events_test.go create mode 100644 lib/jsonlwatcher/testdata/codex_sample.jsonl create mode 100644 lib/jsonlwatcher/testdata/sample.jsonl create mode 100644 lib/jsonlwatcher/types.go create mode 100644 lib/jsonlwatcher/watcher.go create mode 100644 lib/jsonlwatcher/watcher_test.go diff --git a/lib/jsonlwatcher/claude_parser.go b/lib/jsonlwatcher/claude_parser.go new file mode 100644 index 00000000..685bfe37 --- /dev/null +++ b/lib/jsonlwatcher/claude_parser.go @@ -0,0 +1,205 @@ +package jsonlwatcher + +import ( + "encoding/json" + "time" +) + +// ClaudeParser parses Claude Code JSONL session lines into RichMessages. +// +// Claude's JSONL format splits assistant turns across multiple lines, +// each containing one content block but sharing the same message.id. +// This parser groups them by message.id and finalizes when a new +// message.id or user line appears. +type ClaudeParser struct { + pending map[string]*RichMessage + lastPendingID string +} + +// NewClaudeParser creates a new ClaudeParser. +func NewClaudeParser() *ClaudeParser { + return &ClaudeParser{ + pending: make(map[string]*RichMessage), + } +} + +// ParseLine processes a single Claude JSONL line. +func (p *ClaudeParser) ParseLine(line []byte) ([]RichMessage, error) { + var entry JSONLLine + if err := json.Unmarshal(line, &entry); err != nil { + return nil, err + } + + switch entry.Type { + case "assistant": + return p.handleAssistant(&entry), nil + case "user": + return p.handleUser(&entry), nil + default: + return nil, nil + } +} + +// Flush finalizes any pending assistant messages. +func (p *ClaudeParser) Flush() []RichMessage { + return p.finalizePending() +} + +func (p *ClaudeParser) handleAssistant(entry *JSONLLine) []RichMessage { + if entry.Message == nil { + return nil + } + msgID := entry.Message.ID + if msgID == "" { + return nil + } + + var completed []RichMessage + + // If we see a new message.id, finalize the previous pending message + if p.lastPendingID != "" && p.lastPendingID != msgID { + completed = p.finalizePending() + } + + // Get or create the pending message for this message.id + rich, exists := p.pending[msgID] + if !exists { + ts, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + rich = &RichMessage{ + MessageID: msgID, + Role: "assistant", + Model: entry.Message.Model, + Timestamp: ts, + } + p.pending[msgID] = rich + } + + // Update stop_reason and usage from the latest line + if entry.Message.StopReason != nil { + rich.StopReason = *entry.Message.StopReason + } + if entry.Message.Usage != nil { + rich.Usage = entry.Message.Usage + } + + // Parse the content blocks (usually one per line) + var blocks []ContentBlock + if err := json.Unmarshal(entry.Message.Content, &blocks); err == nil { + for _, block := range blocks { + rich.Content = append(rich.Content, contentBlockToRich(block)) + } + } + + p.lastPendingID = msgID + return completed +} + +func (p *ClaudeParser) handleUser(entry *JSONLLine) []RichMessage { + if entry.Message == nil { + return nil + } + + // Finalize any pending assistant message first + completed := p.finalizePending() + + ts, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + rich := RichMessage{ + MessageID: entry.UUID, + Role: "user", + Timestamp: ts, + } + + content := entry.Message.Content + + // Try to parse as a string first (human prompt) + var textContent string + if err := json.Unmarshal(content, &textContent); err == nil { + rich.Content = []RichContentBlock{ + {Type: "text", Text: textContent}, + } + } else { + // Try to parse as array of tool_result blocks + var toolResults []ToolResultBlock + if err := json.Unmarshal(content, &toolResults); err == nil { + for _, tr := range toolResults { + block := RichContentBlock{ + Type: "tool_result", + ToolUseID: tr.ToolUseID, + IsError: tr.IsError, + Text: parseToolResultContent(tr.Content), + } + rich.Content = append(rich.Content, block) + } + } + } + + return append(completed, rich) +} + +func (p *ClaudeParser) finalizePending() []RichMessage { + if len(p.pending) == 0 { + return nil + } + var result []RichMessage + for id, msg := range p.pending { + result = append(result, *msg) + delete(p.pending, id) + } + p.lastPendingID = "" + return result +} + +// contentBlockToRich converts a parsed ContentBlock to a RichContentBlock. +func contentBlockToRich(block ContentBlock) RichContentBlock { + switch block.Type { + case "text": + return RichContentBlock{Type: "text", Text: block.Text} + case "thinking": + return RichContentBlock{Type: "thinking", Thinking: block.Thinking} + case "tool_use": + return RichContentBlock{ + Type: "tool_use", + ToolUseID: block.ID, + ToolName: block.Name, + ToolInput: block.Input, + } + default: + return RichContentBlock{Type: block.Type, Text: block.Text} + } +} + +// parseToolResultContent extracts text from a tool_result content field, +// which can be a string or an array of {type, text} objects. +func parseToolResultContent(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + + var blocks []ToolResultContent + if err := json.Unmarshal(raw, &blocks); err == nil { + var texts []string + for _, b := range blocks { + if b.Text != "" { + texts = append(texts, b.Text) + } + } + if len(texts) == 1 { + return texts[0] + } + result := "" + for i, t := range texts { + if i > 0 { + result += "\n" + } + result += t + } + return result + } + + return string(raw) +} diff --git a/lib/jsonlwatcher/claude_resolver.go b/lib/jsonlwatcher/claude_resolver.go new file mode 100644 index 00000000..4a708e0c --- /dev/null +++ b/lib/jsonlwatcher/claude_resolver.go @@ -0,0 +1,63 @@ +package jsonlwatcher + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ClaudeSessionMeta represents the session metadata from ~/.claude/sessions/.json. +type ClaudeSessionMeta struct { + PID int `json:"pid"` + SessionID string `json:"sessionId"` + CWD string `json:"cwd"` +} + +// ClaudeResolver finds the JSONL session file for a Claude Code process. +type ClaudeResolver struct { + PID int +} + +// Resolve finds the JSONL file path for the configured Claude process PID. +// +// It reads ~/.claude/sessions/.json to get the sessionId and cwd, +// then constructs the path to the JSONL file at +// ~/.claude/projects//.jsonl. +func (r *ClaudeResolver) Resolve() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + sessionFile := filepath.Join(home, ".claude", "sessions", fmt.Sprintf("%d.json", r.PID)) + data, err := os.ReadFile(sessionFile) + if err != nil { + return "", fmt.Errorf("failed to read session file %s: %w", sessionFile, err) + } + + var meta ClaudeSessionMeta + if err := json.Unmarshal(data, &meta); err != nil { + return "", fmt.Errorf("failed to parse session file %s: %w", sessionFile, err) + } + + if meta.SessionID == "" { + return "", fmt.Errorf("session file %s has empty sessionId", sessionFile) + } + if meta.CWD == "" { + return "", fmt.Errorf("session file %s has empty cwd", sessionFile) + } + + encodedCWD := encodeCWD(meta.CWD) + jsonlPath := filepath.Join(home, ".claude", "projects", encodedCWD, meta.SessionID+".jsonl") + + return jsonlPath, nil +} + +// encodeCWD encodes a working directory path for use as a Claude projects +// directory name. It replaces path separators with dashes. +// For example, "/home/k1dave6412" becomes "-home-k1dave6412". +func encodeCWD(cwd string) string { + return strings.ReplaceAll(cwd, string(filepath.Separator), "-") +} diff --git a/lib/jsonlwatcher/codex_parser.go b/lib/jsonlwatcher/codex_parser.go new file mode 100644 index 00000000..a640c131 --- /dev/null +++ b/lib/jsonlwatcher/codex_parser.go @@ -0,0 +1,373 @@ +package jsonlwatcher + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" +) + +// CodexParser parses Codex JSONL session lines into RichMessages. +// +// Codex uses a different format from Claude: +// - Lines have {type, timestamp, payload} envelope +// - response_item/message with role=assistant contains output_text +// - response_item/function_call and function_call_output for tool calls +// - response_item/custom_tool_call and custom_tool_call_output for +// freeform tools (e.g. the exec tool) +// - response_item/reasoning for thinking (content is encrypted) +// - event_msg/token_count for usage info +// - Turns are bounded by event_msg/task_started and task_complete +// +// A Codex task can run for minutes with many tool calls before +// task_complete, so the parser emits incrementally: every appended +// content block re-emits the current turn (same MessageID, consumers +// upsert by ID), and tool results are emitted as soon as their output +// line appears. +type CodexParser struct { + // inTurn tracks whether we're inside a task_started..task_complete boundary. + inTurn bool + // currentTurn accumulates content blocks for the current assistant turn. + currentTurn *RichMessage + // lastUsage holds the most recent token usage from token_count events. + lastUsage *Usage +} + +// NewCodexParser creates a new CodexParser. +func NewCodexParser() *CodexParser { + return &CodexParser{} +} + +// codexLine is the top-level envelope for Codex JSONL. +type codexLine struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Payload json.RawMessage `json:"payload"` +} + +// codexPayload is the shared payload structure. +type codexPayload struct { + Type string `json:"type"` + ID string `json:"id"` + Role string `json:"role"` + Name string `json:"name"` // function_call name + CallID string `json:"call_id"` // function_call/output call_id + Content json.RawMessage `json:"content"` // message content blocks + Output json.RawMessage `json:"output"` // function_call_output + Args json.RawMessage `json:"arguments"` // function_call arguments + Input json.RawMessage `json:"input"` // custom_tool_call input + Status string `json:"status"` // optional tool lifecycle status + Error json.RawMessage `json:"error"` // optional structured tool error + Info *codexTokenInfo `json:"info"` // token_count info +} + +type codexTokenInfo struct { + LastTokenUsage *codexTokenUsage `json:"last_token_usage"` +} + +type codexTokenUsage struct { + InputTokens int `json:"input_tokens"` + CachedInputTokens int `json:"cached_input_tokens"` + OutputTokens int `json:"output_tokens"` + ReasoningTokens int `json:"reasoning_output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// codexContentBlock is a content block in a Codex message. +type codexContentBlock struct { + Type string `json:"type"` // input_text, output_text + Text string `json:"text"` +} + +// ParseLine processes a single Codex JSONL line. +func (p *CodexParser) ParseLine(line []byte) ([]RichMessage, error) { + var entry codexLine + if err := json.Unmarshal(line, &entry); err != nil { + return nil, err + } + + var payload codexPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return nil, err + } + + switch entry.Type { + case "event_msg": + return p.handleEventMsg(&payload, entry.Timestamp), nil + case "response_item": + return p.handleResponseItem(&payload, entry.Timestamp), nil + default: + return nil, nil + } +} + +// Flush finalizes any pending turn. +func (p *CodexParser) Flush() []RichMessage { + return p.finalizeTurn() +} + +func (p *CodexParser) handleEventMsg(payload *codexPayload, timestamp string) []RichMessage { + switch payload.Type { + case "task_started": + // Finalize any previous turn, start a new one + completed := p.finalizeTurn() + p.inTurn = true + return completed + + case "task_complete": + // Finalize the current turn + completed := p.finalizeTurn() + p.inTurn = false + return completed + + case "token_count": + if payload.Info != nil && payload.Info.LastTokenUsage != nil { + u := payload.Info.LastTokenUsage + p.lastUsage = &Usage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheReadInputTokens: u.CachedInputTokens, + } + // Attach usage to current turn if exists + if p.currentTurn != nil { + p.currentTurn.Usage = p.lastUsage + } + } + return nil + + default: + return nil + } +} + +func (p *CodexParser) handleResponseItem(payload *codexPayload, timestamp string) []RichMessage { + switch payload.Type { + case "message": + return p.handleMessage(payload, timestamp) + case "function_call": + return p.handleFunctionCall(payload, timestamp, payload.Args) + case "custom_tool_call": + return p.handleFunctionCall(payload, timestamp, payload.Input) + case "function_call_output", "custom_tool_call_output": + return p.handleFunctionCallOutput(payload, timestamp) + case "reasoning": + return p.handleReasoning(payload, timestamp) + default: + return nil + } +} + +func (p *CodexParser) handleMessage(payload *codexPayload, timestamp string) []RichMessage { + switch payload.Role { + case "assistant": + // Parse content blocks to extract output_text + var blocks []codexContentBlock + if err := json.Unmarshal(payload.Content, &blocks); err != nil { + return nil + } + + var textParts []string + for _, b := range blocks { + if b.Type == "output_text" && b.Text != "" { + textParts = append(textParts, b.Text) + } + } + if len(textParts) == 0 { + return nil + } + + text := textParts[0] + for i := 1; i < len(textParts); i++ { + text += "\n" + textParts[i] + } + + p.ensureCurrentTurn(payload.ID, timestamp) + p.currentTurn.Content = append(p.currentTurn.Content, RichContentBlock{ + Type: "text", + Text: text, + }) + return p.turnSnapshot() + + case "user": + // Extract user prompt text from input_text blocks, skip system/env context + var blocks []codexContentBlock + if err := json.Unmarshal(payload.Content, &blocks); err != nil { + return nil + } + + // Find actual user input (not system context wrapped in XML tags) + var userText string + for _, b := range blocks { + if b.Type == "input_text" && b.Text != "" { + // Skip system context blocks (they start with XML-like tags) + if len(b.Text) > 0 && b.Text[0] == '<' { + continue + } + userText = b.Text + } + } + + if userText == "" { + return nil + } + + // User response_items often have no ID. Fall back to the line + // timestamp so consumers upserting by MessageID don't collapse + // distinct user prompts into one. + msgID := payload.ID + if msgID == "" { + msgID = fmt.Sprintf("user-%s", timestamp) + } + + ts, _ := time.Parse(time.RFC3339Nano, timestamp) + msg := RichMessage{ + MessageID: msgID, + Role: "user", + Timestamp: ts, + Content: []RichContentBlock{ + {Type: "text", Text: userText}, + }, + } + return []RichMessage{msg} + + default: + // Skip developer (system prompt) and other roles + return nil + } +} + +func (p *CodexParser) handleFunctionCall(payload *codexPayload, timestamp string, input json.RawMessage) []RichMessage { + p.ensureCurrentTurn(fmt.Sprintf("turn-%s", payload.CallID), timestamp) + + p.currentTurn.Content = append(p.currentTurn.Content, RichContentBlock{ + Type: "tool_use", + ToolUseID: payload.CallID, + ToolName: payload.Name, + ToolInput: input, + Status: "running", + }) + return p.turnSnapshot() +} + +func (p *CodexParser) handleFunctionCallOutput(payload *codexPayload, timestamp string) []RichMessage { + text := parseCodexOutput(payload.Output) + status, isError := codexToolResultStatus(payload) + + ts, _ := time.Parse(time.RFC3339Nano, timestamp) + // Emit immediately: the corresponding tool_use block was already + // emitted when the function_call line was parsed. + return []RichMessage{{ + MessageID: fmt.Sprintf("result-%s", payload.CallID), + Role: "user", + Timestamp: ts, + Content: []RichContentBlock{ + { + Type: "tool_result", + ToolUseID: payload.CallID, + Text: text, + Status: status, + IsError: &isError, + }, + }, + }} +} + +func codexToolResultStatus(payload *codexPayload) (string, bool) { + switch payload.Status { + case "failed", "incomplete", "cancelled": + return "failed", true + } + if len(payload.Error) > 0 && string(payload.Error) != "null" { + return "failed", true + } + return "completed", false +} + +func (p *CodexParser) handleReasoning(payload *codexPayload, timestamp string) []RichMessage { + p.ensureCurrentTurn(payload.ID, timestamp) + + // Codex reasoning content is encrypted, so we just record its existence + p.currentTurn.Content = append(p.currentTurn.Content, RichContentBlock{ + Type: "thinking", + Thinking: "(encrypted)", + }) + return p.turnSnapshot() +} + +// ensureCurrentTurn creates a new assistant turn if none exists. +func (p *CodexParser) ensureCurrentTurn(msgID string, timestamp string) { + if p.currentTurn == nil { + ts, _ := time.Parse(time.RFC3339Nano, timestamp) + p.currentTurn = &RichMessage{ + MessageID: msgID, + Role: "assistant", + Timestamp: ts, + } + } +} + +// turnSnapshot returns the current turn as a single-element update. +// The same MessageID is re-emitted as content accumulates; consumers +// upsert by (MessageID, Role). +func (p *CodexParser) turnSnapshot() []RichMessage { + if p.currentTurn == nil || len(p.currentTurn.Content) == 0 { + return nil + } + msg := *p.currentTurn + if msg.Usage == nil && p.lastUsage != nil { + msg.Usage = p.lastUsage + } + return []RichMessage{msg} +} + +// finalizeTurn emits the final state of the current turn and resets it. +func (p *CodexParser) finalizeTurn() []RichMessage { + result := p.turnSnapshot() + p.currentTurn = nil + return result +} + +// parseCodexOutput extracts text from a function_call_output's or +// custom_tool_call_output's output field. The field is usually a JSON +// string; for custom tools the string itself may contain a serialized +// array of {type, text} content blocks. +func parseCodexOutput(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + if text, ok := parseCodexOutputBlocks([]byte(s)); ok { + return text + } + return s + } + + if text, ok := parseCodexOutputBlocks(raw); ok { + return text + } + + return string(raw) +} + +// parseCodexOutputBlocks parses a serialized array of {type, text} +// content blocks and joins their text. +func parseCodexOutputBlocks(raw []byte) (string, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '[' { + return "", false + } + var blocks []codexContentBlock + if err := json.Unmarshal(trimmed, &blocks); err != nil { + return "", false + } + var texts []string + for _, b := range blocks { + if b.Text != "" { + texts = append(texts, b.Text) + } + } + return strings.Join(texts, "\n"), true +} diff --git a/lib/jsonlwatcher/codex_parser_test.go b/lib/jsonlwatcher/codex_parser_test.go new file mode 100644 index 00000000..8c710285 --- /dev/null +++ b/lib/jsonlwatcher/codex_parser_test.go @@ -0,0 +1,381 @@ +package jsonlwatcher + +import ( + "os" + "testing" +) + +// applyRich mimics the watcher's upsert-by-(MessageID, Role) behavior so +// tests can assert on the assembled message list. The Codex parser +// re-emits the current turn as content accumulates. +func applyRich(list []RichMessage, msgs []RichMessage) []RichMessage { + for _, msg := range msgs { + idx := -1 + for i := len(list) - 1; i >= 0; i-- { + if list[i].MessageID == msg.MessageID && list[i].Role == msg.Role { + idx = i + break + } + } + if idx >= 0 { + list[idx] = msg + } else { + list = append(list, msg) + } + } + return list +} + +func TestCodexParser_BasicTurn(t *testing.T) { + parser := NewCodexParser() + var all []RichMessage + + // task_started + completed, _ := parser.ParseLine([]byte(`{"type":"event_msg","timestamp":"2026-07-24T10:35:27.000Z","payload":{"type":"task_started"}}`)) + all = applyRich(all, completed) + + // user message (with env context to skip) + completed, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:27.200Z","payload":{"type":"message","id":"user-1","role":"user","content":[{"type":"input_text","text":"system"},{"type":"input_text","text":"List files"}]}}`)) + all = applyRich(all, completed) + + if len(all) != 1 { + t.Fatalf("expected 1 user message, got %d", len(all)) + } + if all[0].Role != "user" { + t.Errorf("Role = %q, want user", all[0].Role) + } + if all[0].Content[0].Text != "List files" { + t.Errorf("user text = %q, want 'List files'", all[0].Content[0].Text) + } + + // reasoning (encrypted) — starts the turn and emits an update + completed, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.000Z","payload":{"type":"reasoning","id":"rs_001","encrypted_content":"gAAA"}}`)) + all = applyRich(all, completed) + + // assistant message — updates the same turn + completed, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.200Z","payload":{"type":"message","id":"msg_001","role":"assistant","content":[{"type":"output_text","text":"Let me check."}]}}`)) + all = applyRich(all, completed) + + // function_call — updates the same turn with a tool_use block + completed, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.300Z","payload":{"type":"function_call","id":"fc_001","name":"exec_command","call_id":"call_abc","arguments":"{\"cmd\":\"ls\"}"}}`)) + all = applyRich(all, completed) + + // The turn is visible incrementally (before task_complete) + if len(all) != 2 { // user + in-progress assistant turn + t.Fatalf("expected 2 messages before tool output, got %d", len(all)) + } + + // function_call_output — emits the tool_result immediately + completed, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:29.000Z","payload":{"type":"function_call_output","id":"fco_001","call_id":"call_abc","output":"main.go"}}`)) + all = applyRich(all, completed) + + if len(all) != 3 { // user + assistant turn + tool_result + t.Fatalf("expected 3 messages after tool output, got %d", len(all)) + } + + // token_count + completed, _ = parser.ParseLine([]byte(`{"type":"event_msg","timestamp":"2026-07-24T10:35:29.100Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":500,"cached_input_tokens":200,"output_tokens":100}}}}`)) + all = applyRich(all, completed) + + // task_complete — re-emits the final turn (upserted, no new entry) + completed, _ = parser.ParseLine([]byte(`{"type":"event_msg","timestamp":"2026-07-24T10:35:31.000Z","payload":{"type":"task_complete"}}`)) + all = applyRich(all, completed) + + // Expected order: [0] user, [1] assistant (thinking+text+tool_use), [2] tool_result + if len(all) != 3 { + t.Fatalf("expected 3 messages total, got %d", len(all)) + } + + // [0] user message + if all[0].Role != "user" { + t.Errorf("all[0].Role = %q, want user", all[0].Role) + } + + // [1] assistant turn — should come BEFORE tool_result + assistantMsg := all[1] + if assistantMsg.Role != "assistant" { + t.Errorf("all[1].Role = %q, want assistant", assistantMsg.Role) + } + if len(assistantMsg.Content) != 3 { // reasoning + text + tool_use + t.Fatalf("expected 3 content blocks in assistant, got %d", len(assistantMsg.Content)) + } + if assistantMsg.Content[0].Type != "thinking" { + t.Errorf("content[0].Type = %q, want thinking", assistantMsg.Content[0].Type) + } + if assistantMsg.Content[1].Type != "text" { + t.Errorf("content[1].Type = %q, want text", assistantMsg.Content[1].Type) + } + if assistantMsg.Content[1].Text != "Let me check." { + t.Errorf("content[1].Text = %q, want 'Let me check.'", assistantMsg.Content[1].Text) + } + if assistantMsg.Content[2].Type != "tool_use" { + t.Errorf("content[2].Type = %q, want tool_use", assistantMsg.Content[2].Type) + } + if assistantMsg.Content[2].ToolName != "exec_command" { + t.Errorf("content[2].ToolName = %q, want exec_command", assistantMsg.Content[2].ToolName) + } + if assistantMsg.Content[2].Status != "running" { + t.Errorf("content[2].Status = %q, want running", assistantMsg.Content[2].Status) + } + // Check usage was attached at finalization + if assistantMsg.Usage == nil { + t.Fatal("expected usage to be set") + } + if assistantMsg.Usage.InputTokens != 500 { + t.Errorf("Usage.InputTokens = %d, want 500", assistantMsg.Usage.InputTokens) + } + + // [2] tool_result — should come AFTER assistant + if all[2].Role != "user" { + t.Errorf("all[2].Role = %q, want user", all[2].Role) + } + if all[2].Content[0].Type != "tool_result" { + t.Errorf("all[2].Content[0].Type = %q, want tool_result", all[2].Content[0].Type) + } + if all[2].Content[0].ToolUseID != "call_abc" { + t.Errorf("ToolUseID = %q, want call_abc", all[2].Content[0].ToolUseID) + } + if all[2].Content[0].Text != "main.go" { + t.Errorf("tool_result text = %q, want 'main.go'", all[2].Content[0].Text) + } + if all[2].Content[0].Status != "completed" { + t.Errorf("tool_result status = %q, want completed", all[2].Content[0].Status) + } + if all[2].Content[0].IsError == nil || *all[2].Content[0].IsError { + t.Error("completed tool_result should have is_error=false") + } +} + +func TestCodexParser_FailedToolResult(t *testing.T) { + parser := NewCodexParser() + + msgs, err := parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:29.000Z","payload":{"type":"function_call_output","call_id":"call_failed","status":"failed","error":{"message":"permission denied"},"output":"permission denied"}}`)) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 1 || len(msgs[0].Content) != 1 { + t.Fatalf("expected one tool result, got %#v", msgs) + } + result := msgs[0].Content[0] + if result.Status != "failed" { + t.Errorf("Status = %q, want failed", result.Status) + } + if result.IsError == nil || !*result.IsError { + t.Error("failed tool_result should have is_error=true") + } +} + +func TestCodexParser_SkipsDeveloperRole(t *testing.T) { + parser := NewCodexParser() + + completed, _ := parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:27.100Z","payload":{"type":"message","id":"sys-1","role":"developer","content":[{"type":"input_text","text":"You are Codex."}]}}`)) + + if len(completed) != 0 { + t.Fatalf("expected 0 messages for developer role, got %d", len(completed)) + } +} + +func TestCodexParser_CustomToolCall(t *testing.T) { + parser := NewCodexParser() + var all []RichMessage + + c, _ := parser.ParseLine([]byte(`{"type":"event_msg","timestamp":"2026-07-24T10:35:27.000Z","payload":{"type":"task_started"}}`)) + all = applyRich(all, c) + + // custom_tool_call (e.g. the exec tool) uses "input" instead of "arguments" + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.000Z","payload":{"type":"custom_tool_call","id":"ct_1","name":"exec","call_id":"call_custom","input":"const r = await tools.exec_command({cmd:\"ls\"})"}}`)) + all = applyRich(all, c) + + if len(all) != 1 { + t.Fatalf("expected 1 message (assistant turn), got %d", len(all)) + } + if all[0].Content[0].Type != "tool_use" { + t.Fatalf("content[0].Type = %q, want tool_use", all[0].Content[0].Type) + } + if all[0].Content[0].ToolName != "exec" { + t.Errorf("ToolName = %q, want exec", all[0].Content[0].ToolName) + } + if string(all[0].Content[0].ToolInput) == "" { + t.Error("expected ToolInput to be set from the input field") + } + + // custom_tool_call_output's output is a string containing a + // serialized array of content blocks + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:29.000Z","payload":{"type":"custom_tool_call_output","id":"cto_1","call_id":"call_custom","output":"[{\"type\":\"input_text\",\"text\":\"Script completed\\nOutput:\\n\"},{\"type\":\"input_text\",\"text\":\"file1.go\"}]"}}`)) + all = applyRich(all, c) + + if len(all) != 2 { + t.Fatalf("expected 2 messages, got %d", len(all)) + } + result := all[1] + if result.Content[0].Type != "tool_result" { + t.Fatalf("content[0].Type = %q, want tool_result", result.Content[0].Type) + } + if result.Content[0].ToolUseID != "call_custom" { + t.Errorf("ToolUseID = %q, want call_custom", result.Content[0].ToolUseID) + } + want := "Script completed\nOutput:\n\nfile1.go" + if result.Content[0].Text != want { + t.Errorf("tool_result text = %q, want %q", result.Content[0].Text, want) + } +} + +func TestCodexParser_SampleFile(t *testing.T) { + data, err := os.ReadFile("testdata/codex_sample.jsonl") + if err != nil { + t.Fatal(err) + } + + parser := NewCodexParser() + var allMessages []RichMessage + + start := 0 + for i := range data { + if data[i] == '\n' { + if i > start { + completed, _ := parser.ParseLine(data[start:i]) + allMessages = applyRich(allMessages, completed) + } + start = i + 1 + } + } + allMessages = applyRich(allMessages, parser.Flush()) + + // Verify ordering: assistant turns appear before their tool_results + assistantIdx := make(map[string]int) // tool_use_id -> index of assistant turn + for i, m := range allMessages { + if m.Role == "assistant" { + for _, c := range m.Content { + if c.Type == "tool_use" { + assistantIdx[c.ToolUseID] = i + } + } + } + if m.Role == "user" { + for _, c := range m.Content { + if c.Type != "tool_result" { + continue + } + aIdx, ok := assistantIdx[c.ToolUseID] + if !ok { + t.Errorf("tool_result %q has no preceding tool_use", c.ToolUseID) + continue + } + if i < aIdx { + t.Errorf("tool_result at index %d appears before its assistant turn at index %d", i, aIdx) + } + } + } + } + + // Verify we have both user and assistant messages + var userCount, assistantCount, toolResultCount int + for _, m := range allMessages { + switch m.Role { + case "user": + for _, c := range m.Content { + if c.Type == "tool_result" { + toolResultCount++ + } + } + userCount++ + case "assistant": + assistantCount++ + } + } + + if userCount < 1 { + t.Errorf("expected at least 1 user message, got %d", userCount) + } + if assistantCount < 1 { + t.Errorf("expected at least 1 assistant turn, got %d", assistantCount) + } + if toolResultCount < 1 { + t.Errorf("expected at least 1 tool_result, got %d", toolResultCount) + } + + // Assistant turn should have usage + for _, m := range allMessages { + if m.Role == "assistant" && m.Usage == nil { + t.Error("assistant turn has no usage") + break + } + } +} + +func TestCodexParser_Flush(t *testing.T) { + parser := NewCodexParser() + var all []RichMessage + + // Start a turn but don't complete it + c, _ := parser.ParseLine([]byte(`{"type":"event_msg","timestamp":"2026-07-24T10:35:27.000Z","payload":{"type":"task_started"}}`)) + all = applyRich(all, c) + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.200Z","payload":{"type":"message","id":"msg_001","role":"assistant","content":[{"type":"output_text","text":"Hello"}]}}`)) + all = applyRich(all, c) + + // Flush should finalize the pending turn + all = applyRich(all, parser.Flush()) + if len(all) != 1 { + t.Fatalf("expected 1 message, got %d", len(all)) + } + if all[0].Content[0].Text != "Hello" { + t.Errorf("flushed text = %q, want Hello", all[0].Content[0].Text) + } +} + +func TestCodexParser_MultipleToolCalls(t *testing.T) { + parser := NewCodexParser() + var all []RichMessage + + // Start turn + c, _ := parser.ParseLine([]byte(`{"type":"event_msg","timestamp":"2026-07-24T10:35:27.000Z","payload":{"type":"task_started"}}`)) + all = applyRich(all, c) + + // Assistant text + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.000Z","payload":{"type":"message","id":"msg_001","role":"assistant","content":[{"type":"output_text","text":"Running commands..."}]}}`)) + all = applyRich(all, c) + + // Two function calls and their outputs + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.100Z","payload":{"type":"function_call","id":"fc_1","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"ls\"}"}}`)) + all = applyRich(all, c) + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.200Z","payload":{"type":"function_call_output","id":"fco_1","call_id":"call_1","output":"file1.go"}}`)) + all = applyRich(all, c) + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.300Z","payload":{"type":"function_call","id":"fc_2","name":"exec_command","call_id":"call_2","arguments":"{\"cmd\":\"cat file1.go\"}"}}`)) + all = applyRich(all, c) + c, _ = parser.ParseLine([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.400Z","payload":{"type":"function_call_output","id":"fco_2","call_id":"call_2","output":"package main"}}`)) + all = applyRich(all, c) + + // task_complete + c, _ = parser.ParseLine([]byte(`{"type":"event_msg","timestamp":"2026-07-24T10:35:29.000Z","payload":{"type":"task_complete"}}`)) + all = applyRich(all, c) + + // Expected: [0] assistant (text + tool_use + tool_use), [1] tool_result call_1, [2] tool_result call_2 + if len(all) != 3 { + t.Fatalf("expected 3 messages, got %d", len(all)) + } + + // Assistant should have text + 2 tool_use blocks + if all[0].Role != "assistant" { + t.Errorf("all[0].Role = %q, want assistant", all[0].Role) + } + if len(all[0].Content) != 3 { + t.Fatalf("expected 3 content blocks, got %d", len(all[0].Content)) + } + if all[0].Content[0].Type != "text" { + t.Errorf("content[0].Type = %q, want text", all[0].Content[0].Type) + } + if all[0].Content[1].Type != "tool_use" { + t.Errorf("content[1].Type = %q, want tool_use", all[0].Content[1].Type) + } + if all[0].Content[2].Type != "tool_use" { + t.Errorf("content[2].Type = %q, want tool_use", all[0].Content[2].Type) + } + + // Tool results should follow + if all[1].Content[0].ToolUseID != "call_1" { + t.Errorf("all[1] ToolUseID = %q, want call_1", all[1].Content[0].ToolUseID) + } + if all[2].Content[0].ToolUseID != "call_2" { + t.Errorf("all[2] ToolUseID = %q, want call_2", all[2].Content[0].ToolUseID) + } +} diff --git a/lib/jsonlwatcher/codex_resolver.go b/lib/jsonlwatcher/codex_resolver.go new file mode 100644 index 00000000..b2c807a6 --- /dev/null +++ b/lib/jsonlwatcher/codex_resolver.go @@ -0,0 +1,122 @@ +package jsonlwatcher + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// CodexResolver finds the JSONL session file for a Codex process. +// +// Codex stores sessions at ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl. +// There is no PID mapping file, so we scan for the most recent session +// matching the working directory. +type CodexResolver struct { + PID int + CWD string + NotBefore time.Time + SessionsDir string +} + +// codexSessionMeta is the first-line metadata in a Codex session file. +type codexSessionMeta struct { + Timestamp string `json:"timestamp"` + Payload struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + } `json:"payload"` +} + +// Resolve finds the Codex JSONL file by scanning the sessions directory. +// +// It looks in ~/.codex/sessions/ for today's date directory, then scans +// all JSONL files sorted by modification time (newest first), reading each +// file's first line to match the working directory. +func (r *CodexResolver) Resolve() (string, error) { + sessionsDir := r.SessionsDir + if sessionsDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + sessionsDir = filepath.Join(home, ".codex", "sessions") + } + + // Look in today's directory and yesterday's (in case of timezone edge) + now := time.Now().UTC() + candidates := []string{ + filepath.Join(sessionsDir, now.Format("2006"), now.Format("01"), now.Format("02")), + filepath.Join(sessionsDir, now.AddDate(0, 0, -1).Format("2006"), now.AddDate(0, 0, -1).Format("01"), now.AddDate(0, 0, -1).Format("02")), + } + + type fileInfo struct { + path string + modTime time.Time + } + + var files []fileInfo + for _, dir := range candidates { + entries, err := os.ReadDir(dir) + if err != nil { + continue // directory might not exist + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".jsonl" { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, fileInfo{ + path: filepath.Join(dir, entry.Name()), + modTime: info.ModTime(), + }) + } + } + + // Sort by modification time, newest first + sort.Slice(files, func(i, j int) bool { + return files[i].modTime.After(files[j].modTime) + }) + + // Find the newest file that matches our CWD + for _, fi := range files { + meta, err := readCodexSessionMeta(fi.path) + if err != nil { + continue + } + startedAt, err := time.Parse(time.RFC3339Nano, meta.Timestamp) + if err != nil { + continue + } + if meta.Payload.CWD == r.CWD && + (r.NotBefore.IsZero() || !startedAt.Before(r.NotBefore)) { + return fi.path, nil + } + } + + return "", fmt.Errorf("no Codex session file found for cwd %q", r.CWD) +} + +// readCodexSessionMeta reads the first line of a Codex session file. +// The session_meta line can be large (it embeds base_instructions, ~18KB), +// so decode a single JSON value from the stream instead of reading a +// fixed-size buffer. +func readCodexSessionMeta(path string) (*codexSessionMeta, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var meta codexSessionMeta + if err := json.NewDecoder(bufio.NewReader(f)).Decode(&meta); err != nil { + return nil, err + } + return &meta, nil +} diff --git a/lib/jsonlwatcher/codex_resolver_test.go b/lib/jsonlwatcher/codex_resolver_test.go new file mode 100644 index 00000000..e7b81d48 --- /dev/null +++ b/lib/jsonlwatcher/codex_resolver_test.go @@ -0,0 +1,140 @@ +package jsonlwatcher + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestReadCodexSessionMeta_LongFirstLine(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "rollout-test.jsonl") + + // Real session_meta lines embed base_instructions and can exceed 4KB. + instructions := strings.Repeat("You are Codex, an agent based on GPT-5. ", 500) // ~20KB + firstLine := fmt.Sprintf( + `{"timestamp":"2026-07-25T14:21:42.025Z","type":"session_meta","payload":{"session_id":"test-session","cwd":"/home/user/project","base_instructions":{"text":%q}}}`, + instructions, + ) + content := firstLine + "\n" + `{"type":"event_msg","timestamp":"2026-07-25T14:21:43.000Z","payload":{"type":"task_started"}}` + "\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + meta, err := readCodexSessionMeta(path) + if err != nil { + t.Fatalf("readCodexSessionMeta failed: %v", err) + } + if meta.Payload.CWD != "/home/user/project" { + t.Errorf("CWD = %q, want /home/user/project", meta.Payload.CWD) + } + if meta.Timestamp != "2026-07-25T14:21:42.025Z" { + t.Errorf("timestamp = %q, want session metadata timestamp", meta.Timestamp) + } +} + +func TestCodexResolver_IgnoresPreviousSessionForSameCWD(t *testing.T) { + sessionsDir := t.TempDir() + now := time.Now().UTC() + dateDir := filepath.Join( + sessionsDir, + now.Format("2006"), + now.Format("01"), + now.Format("02"), + ) + if err := os.MkdirAll(dateDir, 0o755); err != nil { + t.Fatal(err) + } + + notBefore := now.Add(-time.Minute) + oldPath := writeCodexSession( + t, + dateDir, + "old.jsonl", + "/home/user/project", + notBefore.Add(-time.Minute), + ) + newPath := writeCodexSession( + t, + dateDir, + "new.jsonl", + "/home/user/project", + notBefore.Add(time.Second), + ) + + // An old session may have the newest filesystem mtime because another + // Codex process in the same directory is still writing to it. + if err := os.Chtimes(oldPath, now.Add(time.Minute), now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(newPath, now, now); err != nil { + t.Fatal(err) + } + + resolver := &CodexResolver{ + CWD: "/home/user/project", + NotBefore: notBefore, + SessionsDir: sessionsDir, + } + got, err := resolver.Resolve() + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if got != newPath { + t.Errorf("Resolve() = %q, want current session %q", got, newPath) + } +} + +func TestCodexResolver_WaitsWhenOnlyPreviousSessionExists(t *testing.T) { + sessionsDir := t.TempDir() + now := time.Now().UTC() + dateDir := filepath.Join( + sessionsDir, + now.Format("2006"), + now.Format("01"), + now.Format("02"), + ) + if err := os.MkdirAll(dateDir, 0o755); err != nil { + t.Fatal(err) + } + writeCodexSession( + t, + dateDir, + "old.jsonl", + "/home/user/project", + now.Add(-time.Minute), + ) + + resolver := &CodexResolver{ + CWD: "/home/user/project", + NotBefore: now, + SessionsDir: sessionsDir, + } + if path, err := resolver.Resolve(); err == nil { + t.Fatalf("Resolve() = %q, want no current session", path) + } +} + +func writeCodexSession( + t *testing.T, + dir string, + name string, + cwd string, + timestamp time.Time, +) string { + t.Helper() + path := filepath.Join(dir, name) + content := fmt.Sprintf( + `{"timestamp":%q,"type":"session_meta","payload":{"session_id":%q,"cwd":%q}}`+"\n", + timestamp.Format(time.RFC3339Nano), + strings.TrimSuffix(name, ".jsonl"), + cwd, + ) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} diff --git a/lib/jsonlwatcher/session_events.go b/lib/jsonlwatcher/session_events.go new file mode 100644 index 00000000..c1ad1b23 --- /dev/null +++ b/lib/jsonlwatcher/session_events.go @@ -0,0 +1,214 @@ +package jsonlwatcher + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// SessionEvent is a normalized event suitable for one-record-per-line JSONL +// export. Optional fields are omitted to match the timeline interchange format. +type SessionEvent struct { + EventID int `json:"id" doc:"Monotonic event identifier within this AgentAPI run"` + Kind string `json:"kind" doc:"Event kind: system, text, tool_call, or tool_result"` + Role *string `json:"role,omitempty" doc:"Message role when applicable"` + EventTime time.Time `json:"time" doc:"Timestamp recorded by the agent session"` + Content *string `json:"content,omitempty" doc:"Text or tool result content"` + SessionID *string `json:"session_id,omitempty" doc:"Agent session identifier"` + SourceID *string `json:"source_id,omitempty" doc:"Original agent event or message identifier"` + ToolName *string `json:"tool_name,omitempty" doc:"Tool name for tool_call events"` + ToolInput json.RawMessage `json:"tool_input,omitempty" doc:"Tool input for tool_call events"` + ToolUseID *string `json:"tool_use_id,omitempty" doc:"Identifier pairing a tool call with its result"` +} + +// SessionEventParser converts agent-specific JSONL records to SessionEvents. +type SessionEventParser interface { + ParseSessionEvents(line []byte) ([]SessionEvent, error) +} + +type CodexSessionEventParser struct { + sessionID string +} + +func NewCodexSessionEventParser() *CodexSessionEventParser { + return &CodexSessionEventParser{} +} + +func (p *CodexSessionEventParser) ParseSessionEvents(line []byte) ([]SessionEvent, error) { + var entry codexLine + if err := json.Unmarshal(line, &entry); err != nil { + return nil, err + } + var payload codexPayload + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return nil, err + } + eventTime, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + + if entry.Type == "session_meta" { + var meta struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + CLIVersion string `json:"cli_version"` + } + if err := json.Unmarshal(entry.Payload, &meta); err != nil { + return nil, err + } + p.sessionID = meta.SessionID + content := fmt.Sprintf("session started: %s (codex %s)", meta.CWD, meta.CLIVersion) + return []SessionEvent{newSessionEvent("system", strptr("system"), &content, eventTime, strptr(meta.SessionID), strptr(meta.SessionID))}, nil + } + + sessionID := optionalString(p.sessionID) + sourceID := optionalString(payload.ID) + switch entry.Type { + case "response_item": + switch payload.Type { + case "message": + if payload.Role != "user" && payload.Role != "assistant" { + return nil, nil + } + var blocks []codexContentBlock + if err := json.Unmarshal(payload.Content, &blocks); err != nil { + return nil, err + } + role := optionalString(payload.Role) + var textParts []string + for _, block := range blocks { + if block.Text == "" { + continue + } + if payload.Role == "user" && strings.HasPrefix(block.Text, "<") { + continue + } + textParts = append(textParts, block.Text) + } + if len(textParts) == 0 { + return nil, nil + } + text := strings.Join(textParts, "\n") + return []SessionEvent{newSessionEvent("text", role, &text, eventTime, sessionID, nil)}, nil + case "function_call", "custom_tool_call": + input := payload.Args + if payload.Type == "custom_tool_call" { + input = payload.Input + } + event := newSessionEvent("tool_call", strptr("assistant"), nil, eventTime, sessionID, sourceID) + event.ToolName = optionalString(payload.Name) + event.ToolUseID = optionalString(payload.CallID) + event.ToolInput = normalizeRawJSON(input) + return []SessionEvent{event}, nil + case "function_call_output", "custom_tool_call_output": + content := parseCodexOutput(payload.Output) + event := newSessionEvent("tool_result", nil, &content, eventTime, sessionID, nil) + event.ToolUseID = optionalString(payload.CallID) + return []SessionEvent{event}, nil + } + } + return nil, nil +} + +type ClaudeSessionEventParser struct { + sessionID string +} + +func NewClaudeSessionEventParser() *ClaudeSessionEventParser { + return &ClaudeSessionEventParser{} +} + +func (p *ClaudeSessionEventParser) ParseSessionEvents(line []byte) ([]SessionEvent, error) { + var entry JSONLLine + if err := json.Unmarshal(line, &entry); err != nil { + return nil, err + } + if entry.SessionID != "" { + p.sessionID = entry.SessionID + } + if entry.Message == nil { + return nil, nil + } + eventTime, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + sessionID := optionalString(p.sessionID) + sourceID := optionalString(entry.UUID) + + var text string + if err := json.Unmarshal(entry.Message.Content, &text); err == nil { + return []SessionEvent{newSessionEvent("text", optionalString(entry.Message.Role), &text, eventTime, sessionID, nil)}, nil + } + + var blocks []ContentBlock + if err := json.Unmarshal(entry.Message.Content, &blocks); err == nil { + events := make([]SessionEvent, 0, len(blocks)) + for _, block := range blocks { + switch block.Type { + case "text": + content := block.Text + events = append(events, newSessionEvent("text", optionalString(entry.Message.Role), &content, eventTime, sessionID, nil)) + case "tool_use": + event := newSessionEvent("tool_call", strptr("assistant"), nil, eventTime, sessionID, sourceID) + event.ToolName = optionalString(block.Name) + event.ToolUseID = optionalString(block.ID) + event.ToolInput = normalizeRawJSON(block.Input) + events = append(events, event) + } + } + if len(events) > 0 { + return events, nil + } + } + + var results []ToolResultBlock + if err := json.Unmarshal(entry.Message.Content, &results); err == nil { + events := make([]SessionEvent, 0, len(results)) + for _, result := range results { + content := parseToolResultContent(result.Content) + event := newSessionEvent("tool_result", nil, &content, eventTime, sessionID, nil) + event.ToolUseID = optionalString(result.ToolUseID) + events = append(events, event) + } + return events, nil + } + return nil, nil +} + +func newSessionEvent(kind string, role, content *string, eventTime time.Time, sessionID, sourceID *string) SessionEvent { + return SessionEvent{ + Kind: kind, + Role: role, + Content: content, + SessionID: sessionID, + SourceID: sourceID, + EventTime: eventTime, + } +} + +func normalizeRawJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage("null") + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + encoded, _ := json.Marshal(string(raw)) + return encoded + } + if text, ok := value.(string); ok { + var nested any + if json.Unmarshal([]byte(text), &nested) == nil { + encoded, _ := json.Marshal(nested) + return encoded + } + } + return raw +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func strptr(value string) *string { + return &value +} diff --git a/lib/jsonlwatcher/session_events_test.go b/lib/jsonlwatcher/session_events_test.go new file mode 100644 index 00000000..7abf7682 --- /dev/null +++ b/lib/jsonlwatcher/session_events_test.go @@ -0,0 +1,98 @@ +package jsonlwatcher + +import ( + "encoding/json" + "testing" + "time" +) + +func TestCodexSessionEventParser(t *testing.T) { + parser := NewCodexSessionEventParser() + + events, err := parser.ParseSessionEvents([]byte(`{"type":"session_meta","timestamp":"2026-07-24T10:35:26.383Z","payload":{"session_id":"session-1","cwd":"/work","cli_version":"0.144.6"}}`)) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Kind != "system" { + t.Fatalf("unexpected session event: %#v", events) + } + if events[0].SessionID == nil || *events[0].SessionID != "session-1" { + t.Fatalf("SessionID = %#v, want session-1", events[0].SessionID) + } + + events, err = parser.ParseSessionEvents([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:28.300Z","payload":{"type":"function_call","id":"fc_1","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"ls\"}"}}`)) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Kind != "tool_call" { + t.Fatalf("unexpected tool event: %#v", events) + } + if events[0].ToolName == nil || *events[0].ToolName != "exec_command" { + t.Fatalf("ToolName = %#v", events[0].ToolName) + } + var input map[string]string + if err := json.Unmarshal(events[0].ToolInput, &input); err != nil { + t.Fatal(err) + } + if input["cmd"] != "ls" { + t.Fatalf("ToolInput = %#v", input) + } + if events[0].SourceID == nil || *events[0].SourceID != "fc_1" { + t.Fatalf("SourceID = %#v", events[0].SourceID) + } + + events, err = parser.ParseSessionEvents([]byte(`{"type":"response_item","timestamp":"2026-07-24T10:35:29.000Z","payload":{"type":"function_call_output","id":"out_1","call_id":"call_1","output":"main.go"}}`)) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Kind != "tool_result" { + t.Fatalf("unexpected result event: %#v", events) + } + if events[0].ToolUseID == nil || *events[0].ToolUseID != "call_1" { + t.Fatalf("ToolUseID = %#v", events[0].ToolUseID) + } + if events[0].Role != nil || len(events[0].ToolInput) != 0 || events[0].SourceID != nil { + t.Fatalf("tool result optional fields should be absent: %#v", events[0]) + } +} + +func TestSessionEventJSONLShape(t *testing.T) { + event := SessionEvent{ + EventID: 17, + Kind: "system", + Role: strptr("system"), + Content: strptr("session switched"), + EventTime: mustParseTime(t, "2026-07-17T03:23:14.380896751Z"), + } + data, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + const want = `{"id":17,"kind":"system","role":"system","time":"2026-07-17T03:23:14.380896751Z","content":"session switched"}` + if string(data) != want { + t.Fatalf("event JSON = %s, want %s", data, want) + } +} + +func mustParseTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + t.Fatal(err) + } + return parsed +} + +func TestClaudeSessionEventParser(t *testing.T) { + parser := NewClaudeSessionEventParser() + events, err := parser.ParseSessionEvents([]byte(`{"type":"assistant","uuid":"a1","timestamp":"2026-07-24T09:35:26Z","sessionId":"session-1","message":{"id":"msg","role":"assistant","content":[{"type":"tool_use","id":"tool_1","name":"Bash","input":{"command":"ls"}}]}}`)) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Kind != "tool_call" { + t.Fatalf("unexpected event: %#v", events) + } + if events[0].ToolUseID == nil || *events[0].ToolUseID != "tool_1" { + t.Fatalf("ToolUseID = %#v", events[0].ToolUseID) + } +} diff --git a/lib/jsonlwatcher/testdata/codex_sample.jsonl b/lib/jsonlwatcher/testdata/codex_sample.jsonl new file mode 100644 index 00000000..e776c85a --- /dev/null +++ b/lib/jsonlwatcher/testdata/codex_sample.jsonl @@ -0,0 +1,16 @@ +{"type":"session_meta","timestamp":"2026-07-24T10:35:26.383Z","payload":{"session_id":"test-session","cwd":"/home/user/project","originator":"codex-tui","cli_version":"0.144.6"}} +{"type":"event_msg","timestamp":"2026-07-24T10:35:27.000Z","payload":{"type":"task_started"}} +{"type":"response_item","timestamp":"2026-07-24T10:35:27.100Z","payload":{"type":"message","id":"sys-1","role":"developer","content":[{"type":"input_text","text":"You are Codex, an agent based on GPT-5."}]}} +{"type":"response_item","timestamp":"2026-07-24T10:35:27.200Z","payload":{"type":"message","id":"user-1","role":"user","content":[{"type":"input_text","text":"\n /home/user/project\n"},{"type":"input_text","text":"List the files in the current directory"}]}} +{"type":"event_msg","timestamp":"2026-07-24T10:35:27.300Z","payload":{"type":"user_message"}} +{"type":"response_item","timestamp":"2026-07-24T10:35:28.000Z","payload":{"type":"reasoning","id":"rs_001","summary":[],"encrypted_content":"gAAAA..."}} +{"type":"event_msg","timestamp":"2026-07-24T10:35:28.100Z","payload":{"type":"agent_message"}} +{"type":"response_item","timestamp":"2026-07-24T10:35:28.200Z","payload":{"type":"message","id":"msg_001","role":"assistant","content":[{"type":"output_text","text":"Let me check what files are in your directory."}]}} +{"type":"response_item","timestamp":"2026-07-24T10:35:28.300Z","payload":{"type":"function_call","id":"fc_001","name":"exec_command","call_id":"call_abc123","arguments":"{\"cmd\":\"ls -la\"}"}} +{"type":"response_item","timestamp":"2026-07-24T10:35:29.000Z","payload":{"type":"function_call_output","id":"fco_001","call_id":"call_abc123","output":"total 8\ndrwxr-xr-x 2 user user 4096 Jul 24 10:35 .\n-rw-r--r-- 1 user user 100 Jul 24 10:35 main.go"}} +{"type":"event_msg","timestamp":"2026-07-24T10:35:29.100Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":500,"cached_input_tokens":200,"output_tokens":100,"reasoning_output_tokens":20,"total_tokens":600}}}} +{"type":"response_item","timestamp":"2026-07-24T10:35:30.000Z","payload":{"type":"reasoning","id":"rs_002","summary":[],"encrypted_content":"gBBBB..."}} +{"type":"event_msg","timestamp":"2026-07-24T10:35:30.100Z","payload":{"type":"agent_message"}} +{"type":"response_item","timestamp":"2026-07-24T10:35:30.200Z","payload":{"type":"message","id":"msg_002","role":"assistant","content":[{"type":"output_text","text":"Your directory contains a main.go file."}]}} +{"type":"event_msg","timestamp":"2026-07-24T10:35:30.300Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":800,"cached_input_tokens":400,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":850}}}} +{"type":"event_msg","timestamp":"2026-07-24T10:35:31.000Z","payload":{"type":"task_complete"}} diff --git a/lib/jsonlwatcher/testdata/sample.jsonl b/lib/jsonlwatcher/testdata/sample.jsonl new file mode 100644 index 00000000..38020332 --- /dev/null +++ b/lib/jsonlwatcher/testdata/sample.jsonl @@ -0,0 +1,8 @@ +{"type":"mode","sessionId":"test-session-001","mode":"normal"} +{"type":"permission-mode","sessionId":"test-session-001","permissionMode":"bypassPermissions"} +{"type":"user","uuid":"user-001","parentUuid":null,"timestamp":"2026-07-24T09:35:19.000Z","sessionId":"test-session-001","promptSource":"typed","origin":{"kind":"human"},"message":{"role":"user","content":"Say hello and use the Bash tool to run echo hi"}} +{"type":"assistant","uuid":"asst-001a","parentUuid":"user-001","timestamp":"2026-07-24T09:35:26.000Z","sessionId":"test-session-001","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"thinking","thinking":"The user wants me to say hello and run a bash command.","signature":"sig-001"}],"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":1000,"cache_read_input_tokens":0}}} +{"type":"assistant","uuid":"asst-001b","parentUuid":"asst-001a","timestamp":"2026-07-24T09:35:26.100Z","sessionId":"test-session-001","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"Hello! Let me run that command for you."}],"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":1000,"cache_read_input_tokens":0}}} +{"type":"assistant","uuid":"asst-001c","parentUuid":"asst-001b","timestamp":"2026-07-24T09:35:26.200Z","sessionId":"test-session-001","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"toolu_001","name":"Bash","input":{"command":"echo hi"}}],"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":1000,"cache_read_input_tokens":0}}} +{"type":"user","uuid":"user-002","parentUuid":"asst-001c","timestamp":"2026-07-24T09:35:27.000Z","sessionId":"test-session-001","sourceToolAssistantUUID":"asst-001c","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_001","is_error":null,"content":[{"type":"text","text":"hi\n"}]}]},"toolUseResult":{"stdout":"hi\n","stderr":""}} +{"type":"assistant","uuid":"asst-002a","parentUuid":"user-002","timestamp":"2026-07-24T09:35:28.000Z","sessionId":"test-session-001","message":{"id":"msg_002","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"The command ran successfully and output \"hi\"."}],"stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":30,"cache_creation_input_tokens":0,"cache_read_input_tokens":1000}}} diff --git a/lib/jsonlwatcher/types.go b/lib/jsonlwatcher/types.go new file mode 100644 index 00000000..ad96142e --- /dev/null +++ b/lib/jsonlwatcher/types.go @@ -0,0 +1,115 @@ +package jsonlwatcher + +import ( + "encoding/json" + "time" +) + +// --- JSONL line parsing types (input from Claude Code session files) --- + +// JSONLLine is the raw envelope parsed from each line of the JSONL file. +type JSONLLine struct { + Type string `json:"type"` + UUID string `json:"uuid,omitempty"` + ParentUUID *string `json:"parentUuid,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Message *JSONLMessage `json:"message,omitempty"` + + // For user messages with tool results + ToolUseResult json.RawMessage `json:"toolUseResult,omitempty"` + SourceToolAssistantUUID string `json:"sourceToolAssistantUUID,omitempty"` + + // For user messages from humans + PromptSource string `json:"promptSource,omitempty"` + Origin *JSONLOrigin `json:"origin,omitempty"` +} + +// JSONLOrigin indicates the source of a user message. +type JSONLOrigin struct { + Kind string `json:"kind"` // "human" for typed prompts +} + +// JSONLMessage represents the message field within a JSONL line. +type JSONLMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Model string `json:"model,omitempty"` + Content json.RawMessage `json:"content"` // string (user prompt) or []ContentBlock + StopReason *string `json:"stop_reason,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +// ContentBlock represents a single content block in an assistant message. +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` + ID string `json:"id,omitempty"` // tool_use ID + Name string `json:"name,omitempty"` // tool_use name + Input json.RawMessage `json:"input,omitempty"` // tool_use input +} + +// ToolResultContent represents a content block inside a tool_result. +type ToolResultContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// ToolResultBlock represents a tool_result in a user message. +type ToolResultBlock struct { + Type string `json:"type"` // "tool_result" + ToolUseID string `json:"tool_use_id"` + IsError *bool `json:"is_error,omitempty"` + Content json.RawMessage `json:"content,omitempty"` // string or []ToolResultContent +} + +// Usage captures token usage information. +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} + +// --- Interfaces --- + +// LineParser parses agent-specific JSONL lines into RichMessages. +type LineParser interface { + // ParseLine processes a single JSONL line. + // Returns completed (fully assembled) messages ready to emit. + ParseLine(line []byte) (completed []RichMessage, err error) + // Flush finalizes any pending incomplete messages (e.g., on shutdown). + Flush() []RichMessage +} + +// SessionResolver finds the JSONL file path for a given agent process. +type SessionResolver interface { + Resolve() (string, error) +} + +// --- Rich message output types (exposed via API) --- + +// RichContentBlock is a single content block in a rich message. +type RichContentBlock struct { + Type string `json:"type" doc:"Content block type: text, thinking, tool_use, or tool_result"` + Text string `json:"text,omitempty" doc:"Text content (for text and tool_result blocks)"` + Thinking string `json:"thinking,omitempty" doc:"Thinking/reasoning content"` + ToolUseID string `json:"tool_use_id,omitempty" doc:"Tool use identifier"` + ToolName string `json:"tool_name,omitempty" doc:"Name of the tool being called"` + ToolInput json.RawMessage `json:"tool_input,omitempty" doc:"Tool call input parameters"` + Status string `json:"status,omitempty" doc:"Tool lifecycle status: running, completed, or failed"` + IsError *bool `json:"is_error,omitempty" doc:"Whether the tool result is an error"` +} + +// RichMessage is a fully assembled message with all its content blocks. +type RichMessage struct { + MessageID string `json:"message_id" doc:"Agent's internal message ID"` + Role string `json:"role" doc:"Role of the message author (user or assistant)"` + Content []RichContentBlock `json:"content" doc:"Structured content blocks"` + Model string `json:"model,omitempty" doc:"Model that generated this message"` + StopReason string `json:"stop_reason,omitempty" doc:"Why the model stopped generating"` + Usage *Usage `json:"usage,omitempty" doc:"Token usage information"` + Timestamp time.Time `json:"timestamp" doc:"Timestamp of the message"` +} diff --git a/lib/jsonlwatcher/watcher.go b/lib/jsonlwatcher/watcher.go new file mode 100644 index 00000000..5335aff7 --- /dev/null +++ b/lib/jsonlwatcher/watcher.go @@ -0,0 +1,182 @@ +package jsonlwatcher + +import ( + "bufio" + "context" + "io" + "log/slog" + "os" + "time" +) + +const ( + // pollInterval is how often we check for new content in the JSONL file. + pollInterval = 200 * time.Millisecond + + // sessionPollInterval is how often we check for the session/JSONL file to appear. + sessionPollInterval = 500 * time.Millisecond +) + +// Config for creating a new Watcher. +type Config struct { + Resolver SessionResolver + Parser LineParser + Logger *slog.Logger + OnMessage func(RichMessage) // called when a message is completed or updated + OnLine func([]byte) // called for each complete raw JSONL record +} + +// Watcher tails a JSONL session file and assembles structured messages +// by delegating line parsing to an agent-specific LineParser. Completed +// messages are handed to the OnMessage callback; the watcher itself keeps +// no message state. +type Watcher struct { + resolver SessionResolver + parser LineParser + logger *slog.Logger + onMessage func(RichMessage) + onLine func([]byte) +} + +// New creates a Watcher but does not start it. +func New(cfg Config) *Watcher { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Watcher{ + resolver: cfg.Resolver, + parser: cfg.Parser, + logger: logger, + onMessage: cfg.OnMessage, + onLine: cfg.OnLine, + } +} + +// Start begins tailing the JSONL file. It blocks until ctx is canceled. +// It first uses the resolver to find the file, then enters a tail loop. +func (w *Watcher) Start(ctx context.Context) { + jsonlPath, err := w.waitForSessionFile(ctx) + if err != nil { + // Only fails when ctx is canceled. + return + } + w.logger.Info("Resolved JSONL path", "path", jsonlPath) + + w.tailFile(ctx, jsonlPath) +} + +// waitForSessionFile polls until the resolver can find the JSONL file or +// ctx is canceled. There is deliberately no timeout: the session file may +// only be created when the user sends their first message, which can be +// arbitrarily long after the agent starts. +func (w *Watcher) waitForSessionFile(ctx context.Context) (string, error) { + ticker := time.NewTicker(sessionPollInterval) + defer ticker.Stop() + + for { + path, err := w.resolver.Resolve() + if err == nil { + return path, nil + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + // retry + } + } +} + +// tailFile opens the JSONL file and reads new lines as they're appended. +func (w *Watcher) tailFile(ctx context.Context, path string) { + // Wait for the file to exist + var f *os.File + ticker := time.NewTicker(sessionPollInterval) + defer ticker.Stop() + + for { + var err error + f, err = os.Open(path) + if err == nil { + break + } + if !os.IsNotExist(err) { + w.logger.Error("Failed to open JSONL file", "path", path, "error", err) + return + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + // retry + } + } + defer f.Close() + ticker.Stop() + + w.logger.Info("Opened JSONL file for tailing", "path", path) + + reader := bufio.NewReader(f) + pollTicker := time.NewTicker(pollInterval) + defer pollTicker.Stop() + + for { + // Read all available complete lines + for { + line, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + // Partial line or no more data; wait for more + if len(line) > 0 { + if _, seekErr := f.Seek(-int64(len(line)), io.SeekCurrent); seekErr != nil { + w.logger.Error("Failed to seek back for partial line", "error", seekErr) + } + reader.Reset(f) + } + break + } + w.logger.Error("Error reading JSONL file", "error", err) + return + } + w.processLine(line) + } + + select { + case <-ctx.Done(): + // Flush any pending messages from the parser + w.emit(w.parser.Flush()) + return + case <-pollTicker.C: + // continue reading + } + } +} + +// processLine delegates parsing to the LineParser and handles completed messages. +func (w *Watcher) processLine(line []byte) { + if w.onLine != nil { + w.onLine(line) + } + completed, err := w.parser.ParseLine(line) + if err != nil { + w.logger.Debug("Failed to parse JSONL line", "error", err, "line", string(line[:min(len(line), 100)])) + return + } + + w.emit(completed) +} + +// emit hands completed messages to the OnMessage callback. Parsers may +// re-emit the same message as its content accumulates (e.g. the Codex +// parser emits a turn update per content block); deduplication by +// (MessageID, Role) happens downstream in the EventEmitter. +func (w *Watcher) emit(msgs []RichMessage) { + if w.onMessage == nil { + return + } + for _, msg := range msgs { + w.onMessage(msg) + } +} diff --git a/lib/jsonlwatcher/watcher_test.go b/lib/jsonlwatcher/watcher_test.go new file mode 100644 index 00000000..4a1a48f6 --- /dev/null +++ b/lib/jsonlwatcher/watcher_test.go @@ -0,0 +1,352 @@ +package jsonlwatcher + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// staticResolver is a test helper that returns a fixed path. +type staticResolver struct { + path string +} + +func (r *staticResolver) Resolve() (string, error) { + return r.path, nil +} + +func TestClaudeParser_ContentBlocks(t *testing.T) { + tests := []struct { + name string + raw string + expected []ContentBlock + }{ + { + name: "text block", + raw: `[{"type":"text","text":"hello world"}]`, + expected: []ContentBlock{ + {Type: "text", Text: "hello world"}, + }, + }, + { + name: "thinking block", + raw: `[{"type":"thinking","thinking":"let me think","signature":"sig-1"}]`, + expected: []ContentBlock{ + {Type: "thinking", Thinking: "let me think", Signature: "sig-1"}, + }, + }, + { + name: "tool_use block", + raw: `[{"type":"tool_use","id":"toolu_001","name":"Bash","input":{"command":"ls"}}]`, + expected: []ContentBlock{ + {Type: "tool_use", ID: "toolu_001", Name: "Bash"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var blocks []ContentBlock + if err := json.Unmarshal([]byte(tt.raw), &blocks); err != nil { + t.Fatal(err) + } + if len(blocks) != len(tt.expected) { + t.Fatalf("expected %d blocks, got %d", len(tt.expected), len(blocks)) + } + for i, block := range blocks { + if block.Type != tt.expected[i].Type { + t.Errorf("block[%d].Type = %q, want %q", i, block.Type, tt.expected[i].Type) + } + } + }) + } +} + +func TestContentBlockToRich(t *testing.T) { + tests := []struct { + name string + input ContentBlock + expected RichContentBlock + }{ + { + name: "text", + input: ContentBlock{Type: "text", Text: "hello"}, + expected: RichContentBlock{Type: "text", Text: "hello"}, + }, + { + name: "thinking", + input: ContentBlock{Type: "thinking", Thinking: "hmm"}, + expected: RichContentBlock{Type: "thinking", Thinking: "hmm"}, + }, + { + name: "tool_use", + input: ContentBlock{Type: "tool_use", ID: "t1", Name: "Bash", Input: json.RawMessage(`{"cmd":"ls"}`)}, + expected: RichContentBlock{Type: "tool_use", ToolUseID: "t1", ToolName: "Bash"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := contentBlockToRich(tt.input) + if got.Type != tt.expected.Type { + t.Errorf("Type = %q, want %q", got.Type, tt.expected.Type) + } + if got.Text != tt.expected.Text { + t.Errorf("Text = %q, want %q", got.Text, tt.expected.Text) + } + if got.Thinking != tt.expected.Thinking { + t.Errorf("Thinking = %q, want %q", got.Thinking, tt.expected.Thinking) + } + if got.ToolUseID != tt.expected.ToolUseID { + t.Errorf("ToolUseID = %q, want %q", got.ToolUseID, tt.expected.ToolUseID) + } + if got.ToolName != tt.expected.ToolName { + t.Errorf("ToolName = %q, want %q", got.ToolName, tt.expected.ToolName) + } + }) + } +} + +func TestParseToolResultContent(t *testing.T) { + tests := []struct { + name string + raw string + expected string + }{ + {name: "string content", raw: `"hello"`, expected: "hello"}, + {name: "array content", raw: `[{"type":"text","text":"output here"}]`, expected: "output here"}, + {name: "empty", raw: ``, expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseToolResultContent(json.RawMessage(tt.raw)) + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} + +func TestClaudeParser_AssistantGrouping(t *testing.T) { + parser := NewClaudeParser() + + // Three assistant lines with same message.id (one turn) + lines := []string{ + `{"type":"assistant","uuid":"a1","timestamp":"2026-07-24T09:35:26.000Z","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"thinking","thinking":"let me think"}],"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}`, + `{"type":"assistant","uuid":"a2","timestamp":"2026-07-24T09:35:26.100Z","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"Hello!"}],"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}`, + `{"type":"assistant","uuid":"a3","timestamp":"2026-07-24T09:35:26.200Z","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"echo hi"}}],"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}`, + } + + var allCompleted []RichMessage + for _, line := range lines { + completed, _ := parser.ParseLine([]byte(line)) + allCompleted = append(allCompleted, completed...) + } + + // Nothing finalized yet (all same message.id, no trigger) + if len(allCompleted) != 0 { + t.Fatalf("expected 0 completed during same message.id, got %d", len(allCompleted)) + } + + // A user message triggers finalization + completed, _ := parser.ParseLine([]byte(`{"type":"user","uuid":"u1","timestamp":"2026-07-24T09:35:27.000Z","message":{"role":"user","content":"thanks"}}`)) + + if len(completed) != 2 { // 1 assistant (finalized) + 1 user + t.Fatalf("expected 2 messages after user, got %d", len(completed)) + } + + assistantMsg := completed[0] + if assistantMsg.MessageID != "msg_001" { + t.Errorf("MessageID = %q, want msg_001", assistantMsg.MessageID) + } + if len(assistantMsg.Content) != 3 { + t.Fatalf("expected 3 content blocks, got %d", len(assistantMsg.Content)) + } + if assistantMsg.Content[0].Type != "thinking" { + t.Errorf("content[0].Type = %q, want thinking", assistantMsg.Content[0].Type) + } + if assistantMsg.Content[1].Type != "text" { + t.Errorf("content[1].Type = %q, want text", assistantMsg.Content[1].Type) + } + if assistantMsg.Content[2].Type != "tool_use" { + t.Errorf("content[2].Type = %q, want tool_use", assistantMsg.Content[2].Type) + } +} + +func TestClaudeParser_UserToolResult(t *testing.T) { + parser := NewClaudeParser() + + line := `{"type":"user","uuid":"u1","timestamp":"2026-07-24T09:35:27.000Z","sourceToolAssistantUUID":"a3","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_001","is_error":null,"content":[{"type":"text","text":"hi\n"}]}]},"toolUseResult":{"stdout":"hi\n"}}` + completed, _ := parser.ParseLine([]byte(line)) + + if len(completed) != 1 { + t.Fatalf("expected 1 message, got %d", len(completed)) + } + if completed[0].Content[0].Type != "tool_result" { + t.Errorf("content[0].Type = %q, want tool_result", completed[0].Content[0].Type) + } + if completed[0].Content[0].ToolUseID != "toolu_001" { + t.Errorf("content[0].ToolUseID = %q, want toolu_001", completed[0].Content[0].ToolUseID) + } +} + +func TestClaudeParser_DifferentMessageIDs(t *testing.T) { + parser := NewClaudeParser() + + completed1, _ := parser.ParseLine([]byte(`{"type":"assistant","uuid":"a1","timestamp":"2026-07-24T09:35:26.000Z","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"first turn"}],"stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":10}}}`)) + if len(completed1) != 0 { + t.Fatalf("expected 0 completed from first line, got %d", len(completed1)) + } + + completed2, _ := parser.ParseLine([]byte(`{"type":"assistant","uuid":"a2","timestamp":"2026-07-24T09:35:28.000Z","message":{"id":"msg_002","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"second turn"}],"stop_reason":"end_turn","usage":{"input_tokens":200,"output_tokens":20}}}`)) + + // msg_001 should be finalized when msg_002 arrived + if len(completed2) != 1 { + t.Fatalf("expected 1 completed (msg_001 finalized), got %d", len(completed2)) + } + if completed2[0].MessageID != "msg_001" { + t.Errorf("MessageID = %q, want msg_001", completed2[0].MessageID) + } +} + +func TestTailFile(t *testing.T) { + tmpDir := t.TempDir() + jsonlPath := filepath.Join(tmpDir, "test.jsonl") + + // Collect emitted messages, upserting by (MessageID, Role) the same + // way the EventEmitter deduplicates downstream. + var emitted []RichMessage + var mu sync.Mutex + collect := func(msg RichMessage) { + mu.Lock() + defer mu.Unlock() + for i := len(emitted) - 1; i >= 0; i-- { + if emitted[i].MessageID == msg.MessageID && emitted[i].Role == msg.Role { + emitted[i] = msg + return + } + } + emitted = append(emitted, msg) + } + messages := func() []RichMessage { + mu.Lock() + defer mu.Unlock() + return append([]RichMessage(nil), emitted...) + } + + w := New(Config{ + Resolver: &staticResolver{path: jsonlPath}, + Parser: NewClaudeParser(), + OnMessage: collect, + }) + + // Write initial content + f, err := os.Create(jsonlPath) + if err != nil { + t.Fatal(err) + } + f.WriteString(`{"type":"user","uuid":"u1","timestamp":"2026-07-24T09:35:19.000Z","message":{"role":"user","content":"hello"}}` + "\n") + f.Sync() + f.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.tailFile(ctx, jsonlPath) + time.Sleep(500 * time.Millisecond) + + msgs := messages() + if len(msgs) != 1 { + t.Fatalf("expected 1 message after initial read, got %d", len(msgs)) + } + + // Append more data + f, _ = os.OpenFile(jsonlPath, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString(`{"type":"assistant","uuid":"a1","timestamp":"2026-07-24T09:35:26.000Z","message":{"id":"msg_001","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"hi there"}],"stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":10}}}` + "\n") + f.Sync() + f.Close() + time.Sleep(500 * time.Millisecond) + + // Trigger finalization with another user message + f, _ = os.OpenFile(jsonlPath, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString(`{"type":"user","uuid":"u2","timestamp":"2026-07-24T09:35:27.000Z","message":{"role":"user","content":"thanks"}}` + "\n") + f.Sync() + f.Close() + time.Sleep(500 * time.Millisecond) + + msgs = messages() + if len(msgs) != 3 { + t.Fatalf("expected 3 messages after append, got %d", len(msgs)) + } + + cancel() +} + +func TestProcessSampleFile(t *testing.T) { + data, err := os.ReadFile("testdata/sample.jsonl") + if err != nil { + t.Fatal(err) + } + + parser := NewClaudeParser() + var allMessages []RichMessage + + start := 0 + for i := range data { + if data[i] == '\n' { + if i > start { + completed, _ := parser.ParseLine(data[start:i]) + allMessages = append(allMessages, completed...) + } + start = i + 1 + } + } + allMessages = append(allMessages, parser.Flush()...) + + // Expected: user, assistant(3 blocks grouped), user(tool_result), assistant + if len(allMessages) != 4 { + t.Fatalf("expected 4 messages from sample, got %d", len(allMessages)) + } + + if allMessages[0].Role != "user" { + t.Errorf("msgs[0].Role = %q, want user", allMessages[0].Role) + } + if allMessages[1].Role != "assistant" { + t.Errorf("msgs[1].Role = %q, want assistant", allMessages[1].Role) + } + if allMessages[1].MessageID != "msg_001" { + t.Errorf("msgs[1].MessageID = %q, want msg_001", allMessages[1].MessageID) + } + if len(allMessages[1].Content) != 3 { + t.Fatalf("msgs[1] expected 3 content blocks, got %d", len(allMessages[1].Content)) + } + if allMessages[2].Content[0].Type != "tool_result" { + t.Errorf("msgs[2].Content[0].Type = %q, want tool_result", allMessages[2].Content[0].Type) + } + if allMessages[3].StopReason != "end_turn" { + t.Errorf("msgs[3].StopReason = %q, want end_turn", allMessages[3].StopReason) + } +} + +func TestEncodeCWD(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/home/k1dave6412", "-home-k1dave6412"}, + {"/", "-"}, + {"/home/user/projects/myapp", "-home-user-projects-myapp"}, + } + + for _, tt := range tests { + got := encodeCWD(tt.input) + if got != tt.expected { + t.Errorf("encodeCWD(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} From 3922c172f7bf715dff79764a87e56c8219161cda Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 07:21:36 +0000 Subject: [PATCH 023/122] feat(httpapi): add message queue, rich messages, session export, and SSE heartbeat - POST /message now queues user messages while the agent is busy (queued: true) instead of returning an error. A polling dispatch loop sends the queue head when the agent is stable, retries transient validation failures, and drops poison messages after 5 consecutive failures with an agent_error event. New GET/PUT/DELETE /queue endpoints manage pending messages in FIFO order. - Wire the JSONL watcher into the server: GET /rich-messages and the rich_message_update SSE event expose structured content blocks, model info, and token usage. The event emitter is the single store (upsert by message id and role, late-subscriber replay). - GET /session/export downloads all normalized session events for the current run with stable event ids. - /events sends a heartbeat every 15s so clients can detect connections that died without a FIN (e.g. after system sleep). Co-Authored-By: Claude --- cmd/server/server.go | 15 + lib/httpapi/events.go | 86 +++++- lib/httpapi/events_test.go | 20 ++ lib/httpapi/models.go | 45 ++- lib/httpapi/server.go | 244 ++++++++++++++++- lib/httpapi/server_test.go | 62 +++++ openapi.json | 543 ++++++++++++++++++++++++++++++++++++- 7 files changed, 1002 insertions(+), 13 deletions(-) diff --git a/cmd/server/server.go b/cmd/server/server.go index cc53aa17..4387af43 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -161,6 +161,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er transport := "pty" var process *termexec.Process var acpResult *httpapi.SetupACPResult + var agentStartedAt time.Time if printOpenAPI { agentIO = nil @@ -177,6 +178,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er agentIO = acpIO transport = "acp" } else { + agentStartedAt = time.Now() proc, err := httpapi.SetupProcess(ctx, httpapi.SetupProcessConfig{ Program: agent, ProgramArgs: argsToPass[1:], @@ -191,6 +193,16 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er agentIO = proc } port := viper.GetInt(FlagPort) + + // Extract the agent process PID for the JSONL watcher. + // The watcher uses this to find the agent's session JSONL file. + var agentPID int + if process != nil { + agentPID = process.Pid() + } + + cwd, _ := os.Getwd() + srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ AgentType: agentType, AgentIO: agentIO, @@ -200,6 +212,9 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er AllowedHosts: viper.GetStringSlice(FlagAllowedHosts), AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins), InitialPrompt: initialPrompt, + AgentPID: agentPID, + AgentStartedAt: agentStartedAt, + CWD: cwd, StatePersistenceConfig: screentracker.StatePersistenceConfig{ StateFile: stateFile, LoadState: loadState, diff --git a/lib/httpapi/events.go b/lib/httpapi/events.go index c47f6801..4e2812cc 100644 --- a/lib/httpapi/events.go +++ b/lib/httpapi/events.go @@ -2,12 +2,14 @@ package httpapi import ( "fmt" + "slices" "strings" "sync" "time" "github.com/coder/quartz" + "github.com/coder/agentapi/lib/jsonlwatcher" mf "github.com/coder/agentapi/lib/msgfmt" st "github.com/coder/agentapi/lib/screentracker" "github.com/coder/agentapi/lib/util" @@ -17,10 +19,12 @@ import ( type EventType string const ( - EventTypeMessageUpdate EventType = "message_update" - EventTypeStatusChange EventType = "status_change" - EventTypeScreenUpdate EventType = "screen_update" - EventTypeError EventType = "agent_error" + EventTypeMessageUpdate EventType = "message_update" + EventTypeStatusChange EventType = "status_change" + EventTypeScreenUpdate EventType = "screen_update" + EventTypeError EventType = "agent_error" + EventTypeRichMessageUpdate EventType = "rich_message_update" + EventTypeHeartbeat EventType = "heartbeat" ) type AgentStatus string @@ -61,6 +65,16 @@ type ErrorBody struct { Time time.Time `json:"time" doc:"Timestamp when the error occurred"` } +// RichMessageUpdateBody is the SSE payload for rich message updates. +type RichMessageUpdateBody = jsonlwatcher.RichMessage + +// HeartbeatBody is a periodic SSE keep-alive. It lets clients detect +// connections that died without a FIN (e.g. after system sleep), which +// otherwise never produce an error on the client side. +type HeartbeatBody struct { + Time time.Time `json:"time" doc:"Server time when the heartbeat was sent"` +} + type Event struct { Type EventType Payload any @@ -69,6 +83,9 @@ type Event struct { type EventEmitter struct { mu sync.Mutex messages []st.ConversationMessage + richMessages []jsonlwatcher.RichMessage + sessionEvents []jsonlwatcher.SessionEvent + nextSessionEventID int status AgentStatus agentType mf.AgentType chans map[int]chan Event @@ -124,6 +141,8 @@ func WithClock(clock quartz.Clock) EventEmitterOption { func NewEventEmitter(opts ...EventEmitterOption) *EventEmitter { e := &EventEmitter{ messages: make([]st.ConversationMessage, 0), + sessionEvents: make([]jsonlwatcher.SessionEvent, 0), + nextSessionEventID: 1, status: AgentStatusRunning, chans: make(map[int]chan Event), subscriptionBufSize: defaultSubscriptionBufSize, @@ -236,6 +255,57 @@ func (e *EventEmitter) EmitError(message string, level st.ErrorLevel) { e.notifyChannels(EventTypeError, errorBody) } +// EmitRichMessage emits a rich structured message from the JSONL watcher. +// It stores the message for late subscriber replay. Messages are upserted +// by (MessageID, Role) because parsers re-emit a message as its content +// accumulates (e.g. Codex turn updates). +func (e *EventEmitter) EmitRichMessage(msg jsonlwatcher.RichMessage) { + e.mu.Lock() + defer e.mu.Unlock() + + idx := -1 + // Search from the end: updates target recent messages. + for i := len(e.richMessages) - 1; i >= 0; i-- { + if e.richMessages[i].MessageID == msg.MessageID && e.richMessages[i].Role == msg.Role { + idx = i + break + } + } + if idx >= 0 { + e.richMessages[idx] = msg + } else { + e.richMessages = append(e.richMessages, msg) + } + e.notifyChannels(EventTypeRichMessageUpdate, RichMessageUpdateBody(msg)) +} + +// RichMessages returns a snapshot of all rich messages received so far. +// The emitter is the single store for rich messages: the JSONL watcher +// parses and emits, the emitter deduplicates, replays, and serves reads. +func (e *EventEmitter) RichMessages() []jsonlwatcher.RichMessage { + e.mu.Lock() + defer e.mu.Unlock() + return slices.Clone(e.richMessages) +} + +// EmitSessionEvents stores normalized events in the same order as the source +// JSONL records and assigns stable identifiers for the lifetime of this run. +func (e *EventEmitter) EmitSessionEvents(events []jsonlwatcher.SessionEvent) { + e.mu.Lock() + defer e.mu.Unlock() + for _, event := range events { + event.EventID = e.nextSessionEventID + e.nextSessionEventID++ + e.sessionEvents = append(e.sessionEvents, event) + } +} + +func (e *EventEmitter) SessionEvents() []jsonlwatcher.SessionEvent { + e.mu.Lock() + defer e.mu.Unlock() + return slices.Clone(e.sessionEvents) +} + // Assumes the caller holds the lock. func (e *EventEmitter) currentStateAsEvents() []Event { events := make([]Event, 0, len(e.messages)+2) @@ -262,6 +332,14 @@ func (e *EventEmitter) currentStateAsEvents() []Event { }) } + // Include all rich message events for late subscriber replay + for _, msg := range e.richMessages { + events = append(events, Event{ + Type: EventTypeRichMessageUpdate, + Payload: RichMessageUpdateBody(msg), + }) + } + return events } diff --git a/lib/httpapi/events_test.go b/lib/httpapi/events_test.go index a93bde05..69ebea30 100644 --- a/lib/httpapi/events_test.go +++ b/lib/httpapi/events_test.go @@ -1,15 +1,34 @@ package httpapi import ( + "encoding/json" "fmt" "testing" "time" + "github.com/coder/agentapi/lib/jsonlwatcher" st "github.com/coder/agentapi/lib/screentracker" "github.com/coder/quartz" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestEventEmitterSessionEvents(t *testing.T) { + emitter := NewEventEmitter() + emitter.EmitSessionEvents([]jsonlwatcher.SessionEvent{ + {Kind: "text"}, + {Kind: "tool_call", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, + }) + + events := emitter.SessionEvents() + require.Len(t, events, 2) + require.Equal(t, 1, events[0].EventID) + require.Equal(t, 2, events[1].EventID) + + events[0].Kind = "changed" + require.Equal(t, "text", emitter.SessionEvents()[0].Kind) +} + func TestEventEmitter(t *testing.T) { t.Run("single-subscription", func(t *testing.T) { emitter := NewEventEmitter(WithSubscriptionBufSize(10)) @@ -198,4 +217,5 @@ func TestEventEmitter(t *testing.T) { assert.Equal(t, st.ErrorLevelWarning, errorBody.Level) assert.Equal(t, newTime, errorBody.Time) }) + } diff --git a/lib/httpapi/models.go b/lib/httpapi/models.go index 8f4a587d..9ea4ba95 100644 --- a/lib/httpapi/models.go +++ b/lib/httpapi/models.go @@ -3,6 +3,7 @@ package httpapi import ( "time" + "github.com/coder/agentapi/lib/jsonlwatcher" mf "github.com/coder/agentapi/lib/msgfmt" st "github.com/coder/agentapi/lib/screentracker" "github.com/coder/agentapi/lib/util" @@ -78,10 +79,52 @@ type MessageRequest struct { // MessageResponse represents a newly created message type MessageResponse struct { Body struct { - Ok bool `json:"ok" doc:"Indicates whether the message was sent successfully. For messages of type 'user', success means detecting that the agent began executing the task described. For messages of type 'raw', success means the keystrokes were sent to the terminal."` + Ok bool `json:"ok" doc:"Indicates whether the message was accepted."` + Queued bool `json:"queued" doc:"Indicates whether a user message was queued because the agent was busy."` } } +type QueuedMessage struct { + ID int `json:"id" doc:"Unique identifier for the queued message."` + Content string `json:"content" doc:"Message content."` + Time time.Time `json:"time" doc:"Timestamp when the message was queued."` +} + +type QueueResponse struct { + Body struct { + Messages []QueuedMessage `json:"messages" nullable:"false" doc:"Messages waiting to be sent to the agent."` + } +} + +type UpdateQueuedMessageRequest struct { + ID int `path:"id" doc:"Queued message identifier."` + Body struct { + Content string `json:"content" minLength:"1" doc:"Updated message content."` + } +} + +type DeleteQueuedMessageRequest struct { + ID int `path:"id" doc:"Queued message identifier."` +} + +type QueueMutationResponse struct { + Body struct { + Ok bool `json:"ok"` + } +} + +// RichMessagesResponse represents the list of rich structured messages +type RichMessagesResponse struct { + Body struct { + Messages []jsonlwatcher.RichMessage `json:"messages" nullable:"false" doc:"List of rich messages with structured content blocks, model info, and usage"` + } +} + +type SessionExportResponse struct { + ContentDisposition string `header:"Content-Disposition"` + Body []jsonlwatcher.SessionEvent +} + type UploadResponse struct { Body struct { Ok bool `json:"ok" doc:"Indicates whether the files were uploaded successfully."` diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index 35a9bca7..be9784ff 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -21,6 +21,7 @@ import ( "unicode" "github.com/coder/agentapi/internal/version" + "github.com/coder/agentapi/lib/jsonlwatcher" "github.com/coder/agentapi/lib/logctx" mf "github.com/coder/agentapi/lib/msgfmt" st "github.com/coder/agentapi/lib/screentracker" @@ -35,6 +36,18 @@ import ( "golang.org/x/xerrors" ) +const ( + // messageQueueDispatchInterval is how often the queue dispatch loop + // checks whether the head of the queue can be sent to the agent. + messageQueueDispatchInterval = 500 * time.Millisecond + // sseHeartbeatInterval is how often a heartbeat event is sent to each + // /events subscriber so clients can detect silently dead connections. + sseHeartbeatInterval = 15 * time.Second + // maxQueueDispatchAttempts is how many consecutive non-transient send + // failures are tolerated before a queued message is dropped. + maxQueueDispatchAttempts = 5 +) + // Server represents the HTTP server type Server struct { router chi.Router @@ -54,6 +67,12 @@ type Server struct { shutdownCtx context.Context shutdown context.CancelFunc transport Transport + messageQueue []QueuedMessage + nextQueueID int + // Consecutive non-validation dispatch failures for the queued message + // identified by queueFailID. Used to drop poison messages. + queueFailID int + queueFailCount int } func (s *Server) NormalizeSchema(schema any) any { @@ -113,6 +132,9 @@ type ServerConfig struct { InitialPrompt string Clock quartz.Clock StatePersistenceConfig st.StatePersistenceConfig + AgentPID int // PID of the agent process, 0 to disable JSONL watcher + AgentStartedAt time.Time + CWD string // Working directory (used by Codex resolver) } // Validate allowed hosts don't contain whitespace, commas, schemes, or ports. @@ -325,6 +347,51 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) { // asynchronously inside conversation.Start() via ReadyForInitialPrompt. if config.AgentIO != nil { s.conversation.Start(ctx) + s.startMessageQueue() + } + + // Start the JSONL watcher to capture rich structured messages. + // This runs alongside the PTY conversation as a sidecar, providing + // structured content blocks, tool calls, thinking, and usage data. + if config.AgentPID > 0 { + var resolver jsonlwatcher.SessionResolver + var parser jsonlwatcher.LineParser + var sessionEventParser jsonlwatcher.SessionEventParser + + switch config.AgentType { + case mf.AgentTypeClaude: + resolver = &jsonlwatcher.ClaudeResolver{PID: config.AgentPID} + parser = jsonlwatcher.NewClaudeParser() + sessionEventParser = jsonlwatcher.NewClaudeSessionEventParser() + case mf.AgentTypeCodex: + resolver = &jsonlwatcher.CodexResolver{ + PID: config.AgentPID, + CWD: config.CWD, + NotBefore: config.AgentStartedAt, + } + parser = jsonlwatcher.NewCodexParser() + sessionEventParser = jsonlwatcher.NewCodexSessionEventParser() + } + + if resolver != nil && parser != nil { + w := jsonlwatcher.New(jsonlwatcher.Config{ + Resolver: resolver, + Parser: parser, + Logger: logger, + OnMessage: func(msg jsonlwatcher.RichMessage) { + emitter.EmitRichMessage(msg) + }, + OnLine: func(line []byte) { + events, err := sessionEventParser.ParseSessionEvents(line) + if err != nil { + logger.Debug("Failed to normalize session event", "error", err) + return + } + emitter.EmitSessionEvents(events) + }, + }) + go w.Start(ctx) + } } return s, nil @@ -394,9 +461,31 @@ func (s *Server) registerRoutes() { o.Description = "Returns a list of messages representing the conversation history with the agent." }) + // GET /rich-messages endpoint + huma.Get(s.api, "/rich-messages", s.getRichMessages, func(o *huma.Operation) { + o.Description = "Returns a list of rich structured messages parsed from the agent's session log. " + + "Each message contains structured content blocks (text, thinking, tool_use, tool_result), " + + "model information, and token usage data. Only available for agent types with session log " + + "support (currently 'claude' and 'codex') running via PTY transport." + }) + + huma.Get(s.api, "/session/export", s.exportSession, func(o *huma.Operation) { + o.Description = "Downloads all normalized events from the current agent session, including text, tool calls, tool results, and system lifecycle events." + }) + // POST /message endpoint huma.Post(s.api, "/message", s.createMessage, func(o *huma.Operation) { - o.Description = "Send a message to the agent. For messages of type 'user', the agent's status must be 'stable' for the operation to complete successfully. Otherwise, this endpoint will return an error." + o.Description = "Send a message to the agent. User messages are queued when the agent is busy." + }) + + huma.Get(s.api, "/queue", s.getQueue, func(o *huma.Operation) { + o.Description = "Returns user messages waiting to be sent to the agent." + }) + huma.Put(s.api, "/queue/{id}", s.updateQueuedMessage, func(o *huma.Operation) { + o.Description = "Updates a queued user message." + }) + huma.Delete(s.api, "/queue/{id}", s.deleteQueuedMessage, func(o *huma.Operation) { + o.Description = "Deletes a queued user message." }) huma.Post(s.api, "/upload", s.uploadFiles, func(o *huma.Operation) { @@ -413,9 +502,11 @@ func (s *Server) registerRoutes() { Middlewares: []func(huma.Context, func(huma.Context)){sseMiddleware}, }, map[string]any{ // Mapping of event type name to Go struct for that event. - "message_update": MessageUpdateBody{}, - "status_change": StatusChangeBody{}, - "agent_error": ErrorBody{}, + "message_update": MessageUpdateBody{}, + "status_change": StatusChangeBody{}, + "agent_error": ErrorBody{}, + "rich_message_update": RichMessageUpdateBody{}, + "heartbeat": HeartbeatBody{}, }, s.subscribeEvents) sse.Register(s.api, huma.Operation{ @@ -470,14 +561,53 @@ func (s *Server) getMessages(ctx context.Context, input *struct{}) (*MessagesRes return resp, nil } +// getRichMessages handles GET /rich-messages +func (s *Server) getRichMessages(ctx context.Context, input *struct{}) (*RichMessagesResponse, error) { + resp := &RichMessagesResponse{} + resp.Body.Messages = s.emitter.RichMessages() + if resp.Body.Messages == nil { + resp.Body.Messages = []jsonlwatcher.RichMessage{} + } + return resp, nil +} + +func (s *Server) exportSession(ctx context.Context, input *struct{}) (*SessionExportResponse, error) { + events := s.emitter.SessionEvents() + if events == nil { + events = []jsonlwatcher.SessionEvent{} + } + return &SessionExportResponse{ + ContentDisposition: `attachment; filename="agentapi-session.jsonl"`, + Body: events, + }, nil +} + // createMessage handles POST /message func (s *Server) createMessage(ctx context.Context, input *MessageRequest) (*MessageResponse, error) { s.mu.Lock() defer s.mu.Unlock() + resp := &MessageResponse{} switch input.Body.Type { case MessageTypeUser: + if strings.TrimSpace(input.Body.Content) == "" { + return nil, huma.Error400BadRequest("message must not be empty") + } + // Enqueue when earlier messages are still waiting, even if the + // agent is stable, so messages are delivered in FIFO order. + if len(s.messageQueue) > 0 || s.conversation.Status() != st.ConversationStatusStable { + s.enqueueMessageLocked(input.Body.Content) + resp.Body.Ok = true + resp.Body.Queued = true + return resp, nil + } if err := s.conversation.Send(FormatMessage(s.agentType, input.Body.Content)...); err != nil { + if errors.Is(err, st.ErrMessageValidationChanging) { + s.enqueueMessageLocked(input.Body.Content) + resp.Body.Ok = true + resp.Body.Queued = true + return resp, nil + } return nil, xerrors.Errorf("failed to send message: %w", err) } case MessageTypeRaw: @@ -486,12 +616,108 @@ func (s *Server) createMessage(ctx context.Context, input *MessageRequest) (*Mes } } - resp := &MessageResponse{} resp.Body.Ok = true return resp, nil } +func (s *Server) getQueue(ctx context.Context, input *struct{}) (*QueueResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + resp := &QueueResponse{} + resp.Body.Messages = append([]QueuedMessage(nil), s.messageQueue...) + return resp, nil +} + +func (s *Server) updateQueuedMessage(ctx context.Context, input *UpdateQueuedMessageRequest) (*QueueMutationResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + content := strings.TrimSpace(input.Body.Content) + if content == "" { + return nil, huma.Error400BadRequest("message must not be empty") + } + for i := range s.messageQueue { + if s.messageQueue[i].ID == input.ID { + s.messageQueue[i].Content = content + resp := &QueueMutationResponse{} + resp.Body.Ok = true + return resp, nil + } + } + return nil, huma.Error404NotFound("queued message not found") +} + +func (s *Server) deleteQueuedMessage(ctx context.Context, input *DeleteQueuedMessageRequest) (*QueueMutationResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + for i := range s.messageQueue { + if s.messageQueue[i].ID == input.ID { + s.messageQueue = append(s.messageQueue[:i], s.messageQueue[i+1:]...) + resp := &QueueMutationResponse{} + resp.Body.Ok = true + return resp, nil + } + } + return nil, huma.Error404NotFound("queued message not found") +} + +func (s *Server) enqueueMessageLocked(content string) { + s.nextQueueID++ + s.messageQueue = append(s.messageQueue, QueuedMessage{ + ID: s.nextQueueID, + Content: content, + Time: s.clock.Now(), + }) +} + +// startMessageQueue starts the queue dispatch loop. Dispatch is driven by +// polling rather than by subscribing to status events: an event subscriber +// channel is closed by the emitter when it fills up (which a slow +// conversation.Send call could cause), and status events don't fire again +// when a message fails transient validation while the agent stays stable. +// Polling is immune to both. +func (s *Server) startMessageQueue() { + s.clock.TickerFunc(s.shutdownCtx, messageQueueDispatchInterval, func() error { + s.dispatchNextQueuedMessage() + return nil + }, "messageQueueDispatch") +} + +func (s *Server) dispatchNextQueuedMessage() { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.messageQueue) == 0 || s.conversation.Status() != st.ConversationStatusStable { + return + } + next := s.messageQueue[0] + if err := s.conversation.Send(FormatMessage(s.agentType, next.Content)...); err != nil { + if errors.Is(err, st.ErrMessageValidationChanging) { + // Agent became busy again; retry on a later tick. + return + } + if s.queueFailID != next.ID { + s.queueFailID = next.ID + s.queueFailCount = 0 + } + s.queueFailCount++ + s.logger.Error("Failed to send queued message", "queueId", next.ID, "attempt", s.queueFailCount, "error", err) + if s.queueFailCount >= maxQueueDispatchAttempts { + // Drop the poison message so it doesn't block the queue forever. + s.messageQueue = s.messageQueue[1:] + s.emitter.EmitError( + fmt.Sprintf("Dropped queued message after %d failed attempts: %v", s.queueFailCount, err), + st.ErrorLevelError, + ) + } + return + } + s.messageQueue = s.messageQueue[1:] +} + // uploadFiles handles POST /upload func (s *Server) uploadFiles(ctx context.Context, input *struct { RawBody huma.MultipartFormFiles[UploadRequest] @@ -553,6 +779,9 @@ func (s *Server) subscribeEvents(ctx context.Context, input *struct{}, send sse. } } + heartbeat := s.clock.NewTicker(sseHeartbeatInterval, "sseHeartbeat") + defer heartbeat.Stop() + for { select { case event, ok := <-ch: @@ -567,6 +796,11 @@ func (s *Server) subscribeEvents(ctx context.Context, input *struct{}, send sse. s.logger.Error("Failed to send event", "subscriberId", subscriberId, "error", err) return } + case <-heartbeat.C: + if err := send.Data(HeartbeatBody{Time: s.clock.Now()}); err != nil { + s.logger.Error("Failed to send heartbeat", "subscriberId", subscriberId, "error", err) + return + } case <-s.shutdownCtx.Done(): s.logger.Info("Server stop initiated, unsubscribing.", "subscriberId", subscriberId) return diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index ea3b941e..23d48fa0 100644 --- a/lib/httpapi/server_test.go +++ b/lib/httpapi/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "log/slog" "mime/multipart" @@ -106,6 +107,67 @@ func TestServer_redirectToChat(t *testing.T) { } } +func TestServer_MessageQueueAPI(t *testing.T) { + ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil))) + srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ + AgentType: msgfmt.AgentTypeClaude, + AgentIO: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: []string{"*"}, + AllowedOrigins: []string{"*"}, + }) + require.NoError(t, err) + tsServer := httptest.NewServer(srv.Handler()) + t.Cleanup(tsServer.Close) + + postBody := bytes.NewBufferString(`{"content":"queued task","type":"user"}`) + resp, err := http.Post(tsServer.URL+"/message", "application/json", postBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var posted struct { + Ok bool `json:"ok"` + Queued bool `json:"queued"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&posted)) + require.True(t, posted.Ok) + require.True(t, posted.Queued) + + resp, err = http.Get(tsServer.URL + "/queue") + require.NoError(t, err) + defer resp.Body.Close() + var queue struct { + Messages []httpapi.QueuedMessage `json:"messages"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&queue)) + require.Len(t, queue.Messages, 1) + require.Equal(t, "queued task", queue.Messages[0].Content) + + updateBody := bytes.NewBufferString(`{"content":"updated task"}`) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/queue/%d", tsServer.URL, queue.Messages[0].ID), updateBody) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/queue/%d", tsServer.URL, queue.Messages[0].ID), nil) + require.NoError(t, err) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + resp, err = http.Get(tsServer.URL + "/queue") + require.NoError(t, err) + defer resp.Body.Close() + require.NoError(t, json.NewDecoder(resp.Body).Decode(&queue)) + require.Empty(t, queue.Messages) +} + func TestServer_AllowedHosts(t *testing.T) { cases := []struct { name string diff --git a/openapi.json b/openapi.json index aac72a2b..eab7069e 100644 --- a/openapi.json +++ b/openapi.json @@ -119,6 +119,20 @@ }, "type": "object" }, + "HeartbeatBody": { + "additionalProperties": false, + "properties": { + "time": { + "description": "Server time when the heartbeat was sent", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "time" + ], + "type": "object" + }, "Message": { "additionalProperties": false, "properties": { @@ -187,12 +201,17 @@ "type": "string" }, "ok": { - "description": "Indicates whether the message was sent successfully. For messages of type 'user', success means detecting that the agent began executing the task described. For messages of type 'raw', success means the keystrokes were sent to the terminal.", + "description": "Indicates whether the message was accepted.", + "type": "boolean" + }, + "queued": { + "description": "Indicates whether a user message was queued because the agent was busy.", "type": "boolean" } }, "required": [ - "ok" + "ok", + "queued" ], "type": "object" }, @@ -258,6 +277,181 @@ ], "type": "object" }, + "QueueMutationResponseBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "example": "https://example.com/schemas/QueueMutationResponseBody.json", + "format": "uri", + "readOnly": true, + "type": "string" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "QueueResponseBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "example": "https://example.com/schemas/QueueResponseBody.json", + "format": "uri", + "readOnly": true, + "type": "string" + }, + "messages": { + "description": "Messages waiting to be sent to the agent.", + "items": { + "$ref": "#/components/schemas/QueuedMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "QueuedMessage": { + "additionalProperties": false, + "properties": { + "content": { + "description": "Message content.", + "type": "string" + }, + "id": { + "description": "Unique identifier for the queued message.", + "format": "int64", + "type": "integer" + }, + "time": { + "description": "Timestamp when the message was queued.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "content", + "id", + "time" + ], + "type": "object" + }, + "RichContentBlock": { + "additionalProperties": false, + "properties": { + "is_error": { + "description": "Whether the tool result is an error", + "type": "boolean" + }, + "status": { + "description": "Tool lifecycle status: running, completed, or failed", + "type": "string" + }, + "text": { + "description": "Text content (for text and tool_result blocks)", + "type": "string" + }, + "thinking": { + "description": "Thinking/reasoning content", + "type": "string" + }, + "tool_input": { + "description": "Tool call input parameters" + }, + "tool_name": { + "description": "Name of the tool being called", + "type": "string" + }, + "tool_use_id": { + "description": "Tool use identifier", + "type": "string" + }, + "type": { + "description": "Content block type: text, thinking, tool_use, or tool_result", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "RichMessage": { + "additionalProperties": false, + "properties": { + "content": { + "description": "Structured content blocks", + "items": { + "$ref": "#/components/schemas/RichContentBlock" + }, + "nullable": true, + "type": "array" + }, + "message_id": { + "description": "Agent's internal message ID", + "type": "string" + }, + "model": { + "description": "Model that generated this message", + "type": "string" + }, + "role": { + "description": "Role of the message author (user or assistant)", + "type": "string" + }, + "stop_reason": { + "description": "Why the model stopped generating", + "type": "string" + }, + "timestamp": { + "description": "Timestamp of the message", + "format": "date-time", + "type": "string" + }, + "usage": { + "$ref": "#/components/schemas/Usage", + "description": "Token usage information" + } + }, + "required": [ + "content", + "message_id", + "role", + "timestamp" + ], + "type": "object" + }, + "RichMessagesResponseBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "example": "https://example.com/schemas/RichMessagesResponseBody.json", + "format": "uri", + "readOnly": true, + "type": "string" + }, + "messages": { + "description": "List of rich messages with structured content blocks, model info, and usage", + "items": { + "$ref": "#/components/schemas/RichMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, "ScreenUpdateBody": { "additionalProperties": false, "properties": { @@ -270,6 +464,58 @@ ], "type": "object" }, + "SessionEvent": { + "additionalProperties": false, + "properties": { + "content": { + "description": "Text or tool result content", + "type": "string" + }, + "id": { + "description": "Monotonic event identifier within this AgentAPI run", + "format": "int64", + "type": "integer" + }, + "kind": { + "description": "Event kind: system, text, tool_call, or tool_result", + "type": "string" + }, + "role": { + "description": "Message role when applicable", + "type": "string" + }, + "session_id": { + "description": "Agent session identifier", + "type": "string" + }, + "source_id": { + "description": "Original agent event or message identifier", + "type": "string" + }, + "time": { + "description": "Timestamp recorded by the agent session", + "format": "date-time", + "type": "string" + }, + "tool_input": { + "description": "Tool input for tool_call events" + }, + "tool_name": { + "description": "Tool name for tool_call events", + "type": "string" + }, + "tool_use_id": { + "description": "Identifier pairing a tool call with its result", + "type": "string" + } + }, + "required": [ + "id", + "kind", + "time" + ], + "type": "object" + }, "StatusChangeBody": { "additionalProperties": false, "properties": { @@ -327,6 +573,27 @@ "title": "Transport", "type": "string" }, + "UpdateQueuedMessageRequestBody": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "example": "https://example.com/schemas/UpdateQueuedMessageRequestBody.json", + "format": "uri", + "readOnly": true, + "type": "string" + }, + "content": { + "description": "Updated message content.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "content" + ], + "type": "object" + }, "UploadResponseBody": { "additionalProperties": false, "properties": { @@ -351,6 +618,34 @@ "ok" ], "type": "object" + }, + "Usage": { + "additionalProperties": false, + "properties": { + "cache_creation_input_tokens": { + "format": "int64", + "type": "integer" + }, + "cache_read_input_tokens": { + "format": "int64", + "type": "integer" + }, + "input_tokens": { + "format": "int64", + "type": "integer" + }, + "output_tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cache_creation_input_tokens", + "cache_read_input_tokens", + "input_tokens", + "output_tokens" + ], + "type": "object" } } }, @@ -399,6 +694,32 @@ "title": "Event agent_error", "type": "object" }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/HeartbeatBody" + }, + "event": { + "const": "heartbeat", + "description": "The event name.", + "type": "string" + }, + "id": { + "description": "The event ID.", + "type": "integer" + }, + "retry": { + "description": "The retry time in milliseconds.", + "type": "integer" + } + }, + "required": [ + "data", + "event" + ], + "title": "Event heartbeat", + "type": "object" + }, { "properties": { "data": { @@ -425,6 +746,32 @@ "title": "Event message_update", "type": "object" }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/RichMessage" + }, + "event": { + "const": "rich_message_update", + "description": "The event name.", + "type": "string" + }, + "id": { + "description": "The event ID.", + "type": "integer" + }, + "retry": { + "description": "The retry time in milliseconds.", + "type": "integer" + } + }, + "required": [ + "data", + "event" + ], + "title": "Event rich_message_update", + "type": "object" + }, { "properties": { "data": { @@ -476,7 +823,7 @@ }, "/message": { "post": { - "description": "Send a message to the agent. For messages of type 'user', the agent's status must be 'stable' for the operation to complete successfully. Otherwise, this endpoint will return an error.", + "description": "Send a message to the agent. User messages are queued when the agent is busy.", "operationId": "post-message", "requestBody": { "content": { @@ -543,6 +890,196 @@ "summary": "Get messages" } }, + "/queue": { + "get": { + "description": "Returns user messages waiting to be sent to the agent.", + "operationId": "get-queue", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueResponseBody" + } + } + }, + "description": "OK" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Get queue" + } + }, + "/queue/{id}": { + "delete": { + "description": "Deletes a queued user message.", + "operationId": "delete-queue-by-id", + "parameters": [ + { + "description": "Queued message identifier.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Queued message identifier.", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueMutationResponseBody" + } + } + }, + "description": "OK" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Delete queue by ID" + }, + "put": { + "description": "Updates a queued user message.", + "operationId": "put-queue-by-id", + "parameters": [ + { + "description": "Queued message identifier.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Queued message identifier.", + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateQueuedMessageRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueMutationResponseBody" + } + } + }, + "description": "OK" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Put queue by ID" + } + }, + "/rich-messages": { + "get": { + "description": "Returns a list of rich structured messages parsed from the agent's session log. Each message contains structured content blocks (text, thinking, tool_use, tool_result), model information, and token usage data. Only available for agent types with session log support (currently 'claude' and 'codex') running via PTY transport.", + "operationId": "get-rich-messages", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RichMessagesResponseBody" + } + } + }, + "description": "OK" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Get rich messages" + } + }, + "/session/export": { + "get": { + "description": "Downloads all normalized events from the current agent session, including text, tool calls, tool results, and system lifecycle events.", + "operationId": "list-session-export", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SessionEvent" + }, + "nullable": true, + "type": "array" + } + } + }, + "description": "OK", + "headers": { + "Content-Disposition": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "List session export" + } + }, "/status": { "get": { "description": "Returns the current status of the agent.", From 70be4349bd1a503732f418df9fb1c2abc41fb23c Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 07:21:43 +0000 Subject: [PATCH 024/122] feat(chat): add session JSONL download Add a header menu action that fetches /session/export and saves the normalized session events as a timestamped .jsonl file. Co-Authored-By: Claude --- chat/src/app/header.tsx | 25 +++++++++++++++++++- chat/src/components/chat-provider.tsx | 33 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/chat/src/app/header.tsx b/chat/src/app/header.tsx index 77fed473..5cb62eb6 100644 --- a/chat/src/app/header.tsx +++ b/chat/src/app/header.tsx @@ -3,10 +3,11 @@ import { type ComponentType, useEffect, useMemo, useState } from "react"; import { AgentType, useChat } from "@/components/chat-provider"; import { ModeToggle } from "@/components/mode-toggle"; -import { Activity, Bot, CircleAlert, CircleCheck, LoaderCircle, WifiOff } from "lucide-react"; +import { Activity, Bot, CircleAlert, CircleCheck, Download, LoaderCircle, WifiOff } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, @@ -20,9 +21,11 @@ export function Header() { queuedMessages, richMessages, messages, + downloadSession, } = useChat(); const [runningSince, setRunningSince] = useState(null); const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [downloading, setDownloading] = useState(false); useEffect(() => { if (serverStatus !== "running") { @@ -186,6 +189,26 @@ export function Header() { value={`${queuedMessages.length} ${queuedMessages.length === 1 ? "task" : "tasks"}`} />
+ + { + setDownloading(true); + void downloadSession() + .catch(() => { + // The provider reports request failures through the + // rejected promise; keep the menu action retryable. + }) + .finally(() => setDownloading(false)); + }} + > + {downloading ? ( + + ) : ( + + )} + Download session JSONL + diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 9c1782f5..63518a08 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -151,6 +151,7 @@ interface ChatContextValue { reconnectAttempt: number; nextReconnectAt: number | null; reconnectNow: () => void; + downloadSession: () => Promise; storageScope: string; agentType: AgentType; } @@ -696,6 +697,37 @@ export function ChatProvider({ children }: PropsWithChildren) { await refreshQueue(); }; + const downloadSession = async () => { + try { + const response = await fetch(`${agentAPIUrl}/session/export`); + if (!response.ok) { + throw new Error("Failed to export the current session"); + } + const events = await response.json(); + if (!Array.isArray(events)) { + throw new Error("The server returned an invalid session export"); + } + const jsonl = events.map((event) => JSON.stringify(event)).join("\n"); + const blob = new Blob([jsonl === "" ? "" : `${jsonl}\n`], { + type: "application/x-ndjson", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const timestamp = new Date().toISOString().replaceAll(":", "-"); + link.href = url; + link.download = `agentapi-session-${timestamp}.jsonl`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (error) { + toast.error("Session download failed", { + description: getErrorMessage(error), + }); + throw error; + } + }; + return ( Date: Sun, 26 Jul 2026 07:21:43 +0000 Subject: [PATCH 025/122] fix(chat): remove fixed-height scroll cap on agent messages Co-Authored-By: Claude --- chat/src/components/message-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index 4352ef6a..91c13d16 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -1323,7 +1323,7 @@ function MessageItem({ {message.content === "" ? ( ) : ( -
+
Date: Sun, 26 Jul 2026 07:21:43 +0000 Subject: [PATCH 026/122] docs: add CLAUDE.md Co-Authored-By: Claude --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8c59867c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +AgentAPI is a Go HTTP server that controls coding agents (Claude Code, Aider, Goose, Codex, Gemini, Copilot, Amp, Cursor, Auggie, AmazonQ, Opencode) through terminal emulation. It runs agents in an in-memory terminal emulator, translates HTTP API calls into terminal keystrokes, and parses terminal output into structured messages. It also embeds a Next.js chat web UI. + +## Build & Run + +```bash +make build # Build binary to out/agentapi (includes chat UI build) +go build -o out/agentapi main.go # Go-only build without chat UI +make embed # Build chat UI and copy into lib/httpapi/chat/ for embedding +make fmt # Format Go code with gofumpt +make gen # Regenerate OpenAPI schema and version (go generate ./...) +make lint # Run all linters (Go, TypeScript, shellcheck, actionlint) +``` + +Chat UI development: +```bash +cd chat && bun install # Install chat dependencies +cd chat && bun run dev # Start Next.js dev server with Turbopack +cd chat && bun lint # Lint TypeScript +``` + +## Testing + +```bash +go test ./... # Run all Go tests +go test ./lib/httpapi/... # Run tests in a specific package +go test -run TestOpenAPISchema ./lib/httpapi/... # Run a single test +go test ./e2e # Run e2e tests (smoke test) +``` + +Tests use `CGO_ENABLED=0`. The project uses `testify` (assert/require) and `coder/quartz` for deterministic time mocking. Tests are colocated with source files. E2e tests in `e2e/` use a scripted echo agent that simulates real agent behavior. + +## Architecture + +### Message Flow +1. User sends message via `POST /message` +2. Server takes a terminal snapshot, sends keystrokes to the agent process +3. A polling loop compares new terminal snapshots against the baseline +4. New content below the baseline becomes the agent's response message +5. SSE events (`GET /events`) stream message and status updates to clients + +### Key Packages +- **`lib/httpapi/`** — HTTP server (chi router + huma for OpenAPI). Routes: `/messages`, `/message`, `/status`, `/events` (SSE), `/queue`, `/upload`, `/rich-messages`. The chat UI is embedded via `//go:embed` from `lib/httpapi/chat/`. +- **`lib/screentracker/`** — Core conversation engine. `Conversation` interface with `PTYConversation` implementation. Manages terminal snapshots, screen diffing, message splitting, and status detection (stable vs. changing). +- **`lib/termexec/`** — Terminal process execution. Wraps PTY creation and process lifecycle. +- **`lib/msgfmt/`** — Agent-specific message formatting. Strips echoed user input and TUI elements (input boxes, borders) from terminal output. Each agent type has different formatting quirks. +- **`lib/jsonlwatcher/`** — Watches agent JSONL session logs (Claude, Codex) for rich structured messages (tool calls, thinking, usage data). Runs as a sidecar alongside PTY. +- **`x/acpio/`** — Experimental ACP (Agent Communication Protocol) transport, alternative to PTY. +- **`cmd/`** — CLI commands via cobra/viper. `server` and `attach` subcommands. + +### Two Transport Modes +- **PTY (default)**: Runs the agent in a terminal emulator, parses screen output. +- **ACP (experimental)**: Uses the Agent Communication Protocol for structured communication (`--experimental-acp`). + +### Adding a New Agent Type +1. Add the `AgentType` constant in `lib/msgfmt/msgfmt.go` +2. Add formatting logic in `lib/msgfmt/` (message box removal, user input stripping) +3. Add readiness detection in `lib/msgfmt/agent_readiness.go` +4. Add alias mapping in `cmd/server/server.go` (`agentTypeAliases`) +5. Add display name in `chat/src/components/chat-provider.tsx` + +### Exhaustive Switch/Map Enforcement +The `exhaustive` golangci-lint checker is enabled for both switches and maps. When adding a new `AgentType` or enum value, all switch statements and map literals over that type must be updated or the linter will fail. + +## Conventions + +- OpenAPI schema is auto-generated: `go run main.go server --print-openapi dummy > openapi.json` (via `go generate`) +- The chat UI build output goes to `lib/httpapi/chat/` with a magic base path placeholder that gets replaced at runtime +- Environment variables use `AGENTAPI_` prefix (e.g., `AGENTAPI_ALLOWED_HOSTS`) +- Server defaults: port 3284, chat at `/chat`, docs at `/docs` From 397736bcad30e9914850f8f97a51ae497e850ecc Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 13:35:05 +0000 Subject: [PATCH 027/122] feat(chat): add terminal view and session explorer --- chat/src/components/chat-provider.tsx | 97 ++++++- chat/src/components/chat.tsx | 59 ++-- chat/src/components/explorer.test.ts | 18 ++ chat/src/components/explorer.tsx | 300 +++++++++++++++++++++ chat/src/components/message-input.tsx | 83 +++--- chat/src/components/message-list.tsx | 163 ++++------- chat/src/components/terminal-screen.tsx | 30 +++ chat/src/stories/message-input.stories.tsx | 2 +- 8 files changed, 572 insertions(+), 180 deletions(-) create mode 100644 chat/src/components/explorer.test.ts create mode 100644 chat/src/components/explorer.tsx create mode 100644 chat/src/components/terminal-screen.tsx diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 63518a08..b19eff11 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -90,6 +90,11 @@ function isDraftMessage(message: Message | DraftMessage): boolean { type MessageType = "user" | "raw"; +export interface SendResult { + ok: boolean; + queued: boolean; +} + export type ServerStatus = "stable" | "running" | "offline" | "unknown"; export type ConnectionStatus = "connected" | "reconnecting" | "offline"; @@ -110,6 +115,17 @@ export interface QueuedMessage { time: string; } +export interface BackgroundTask { + id: string; + name: string; + status: "running" | "completed" | "failed" | "unknown"; + agent_type: string; + tool_use_id: string; + output_path?: string; + started_at: string; + updated_at: string; +} + export type AgentType = "claude" | "goose" | "aider" | "gemini" | "amp" | "codex" | "cursor" | "cursor-agent" | "copilot" | "auggie" | "amazonq" | "opencode" | "custom" | "unknown"; export type AgentColorDisplayNamePair = { @@ -135,11 +151,12 @@ export const AgentType: Record, AgentColorDisplayN interface ChatContextValue { messages: (Message | DraftMessage)[]; richMessages: RichMessage[]; + backgroundTasks: BackgroundTask[]; loading: boolean; serverStatus: ServerStatus; connectionStatus: ConnectionStatus; queuedMessages: QueuedMessage[]; - sendMessage: (message: string, type?: MessageType) => Promise; + sendMessage: (message: string, type?: MessageType) => Promise; retryFailedMessage: (clientId: string) => Promise; dismissFailedMessage: (clientId: string) => void; updateQueuedMessage: (id: number, content: string) => Promise; @@ -152,6 +169,13 @@ interface ChatContextValue { nextReconnectAt: number | null; reconnectNow: () => void; downloadSession: () => Promise; + refreshBackgroundTasks: () => Promise; + getBackgroundTaskOutput: (id: string) => Promise<{ + content: string; + path: string; + size: number; + truncated: boolean; + }>; storageScope: string; agentType: AgentType; } @@ -164,7 +188,7 @@ const STALE_CONNECTION_MS = 45_000; const ChatContext = createContext(undefined); -const useAgentAPIUrl = (): string => { +export const useAgentAPIUrl = (): string => { const searchParams = useSearchParams(); const paramsUrl = searchParams.get("url"); if (paramsUrl) { @@ -202,6 +226,7 @@ const useAgentAPIUrl = (): string => { export function ChatProvider({ children }: PropsWithChildren) { const [messages, setMessages] = useState<(Message | DraftMessage)[]>([]); const [richMessages, setRichMessages] = useState([]); + const [backgroundTasks, setBackgroundTasks] = useState([]); const [loading, setLoading] = useState(false); const [serverStatus, setServerStatus] = useState("unknown"); const [connectionStatus, setConnectionStatus] = @@ -218,6 +243,7 @@ export function ChatProvider({ children }: PropsWithChildren) { const [failedMessagesHydrated, setFailedMessagesHydrated] = useState(false); const agentAPIUrl = useAgentAPIUrl(); const failedMessagesStorageKey = `agentapi.chat.failed-messages:${agentAPIUrl}`; + const reconnectNow = useCallback(() => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); @@ -240,6 +266,41 @@ export function ChatProvider({ children }: PropsWithChildren) { // The connection status handler reports connectivity failures. } }, [agentAPIUrl]); + const refreshBackgroundTasks = useCallback(async () => { + try { + const response = await fetch(`${agentAPIUrl}/background-tasks`); + if (!response.ok) return; + const data = await response.json() as {tasks?: BackgroundTask[]}; + setBackgroundTasks(data.tasks ?? []); + } catch { + // The primary connection state handles connectivity feedback. + } + }, [agentAPIUrl]); + const getBackgroundTaskOutput = useCallback(async (id: string) => { + const response = await fetch( + `${agentAPIUrl}/background-tasks/${encodeURIComponent(id)}/output`, + ); + if (!response.ok) throw new Error("Background task output is unavailable"); + return await response.json() as { + content: string; + path: string; + size: number; + truncated: boolean; + }; + }, [agentAPIUrl]); + + useEffect(() => { + void refreshBackgroundTasks(); + }, [refreshBackgroundTasks]); + + const hasRunningBackgroundTasks = backgroundTasks.some( + (task) => task.status === "running", + ); + useEffect(() => { + if (!hasRunningBackgroundTasks) return; + const timer = window.setInterval(() => void refreshBackgroundTasks(), 3000); + return () => window.clearInterval(timer); + }, [hasRunningBackgroundTasks, refreshBackgroundTasks]); const currentTask = [...messages] .reverse() .find((message) => message.role === "user") @@ -429,6 +490,13 @@ export function ChatProvider({ children }: PropsWithChildren) { updated[existingIndex] = data; return updated; }); + if ( + data.content.some( + (block) => block.type === "tool_use" || block.type === "tool_result", + ) + ) { + void refreshBackgroundTasks(); + } }); // Handle status changes @@ -446,6 +514,7 @@ export function ChatProvider({ children }: PropsWithChildren) { // Set agent type setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType); void refreshQueue(); + void refreshBackgroundTasks(); }); // Handle agent error events @@ -522,15 +591,15 @@ export function ChatProvider({ children }: PropsWithChildren) { eventSourceRef.current?.close(); eventSourceRef.current = null; }; - }, [agentAPIUrl, reconnectNonce, reconnectNow, refreshQueue]); + }, [agentAPIUrl, reconnectNonce, reconnectNow, refreshBackgroundTasks, refreshQueue]); // Send a new message const sendMessage = async ( content: string, type: "user" | "raw" = "user" - ): Promise => { + ): Promise => { // For user messages, require non-empty content - if (type === "user" && !content.trim()) return false; + if (type === "user" && !content.trim()) return {ok: false, queued: false}; const clientId = crypto.randomUUID(); // For raw messages, don't set loading state as it's usually fast @@ -572,6 +641,10 @@ export function ChatProvider({ children }: PropsWithChildren) { const fullDetail = `${detail}: ${messages}`; throw new Error(fullDetail); } + const result = await response.json() as { + ok?: boolean; + queued?: boolean; + }; await refreshQueue(); if (type === "user") { setMessages((previous) => @@ -582,7 +655,10 @@ export function ChatProvider({ children }: PropsWithChildren) { ), ); } - return true; + return { + ok: result.ok === true, + queued: result.queued === true, + }; } catch (error) { console.error("Error sending message:", error); const message = getErrorMessage(error) @@ -604,7 +680,7 @@ export function ChatProvider({ children }: PropsWithChildren) { ), ); } - return false; + return {ok: false, queued: false}; } finally { if (type === "user") { setLoading(false); @@ -630,7 +706,8 @@ export function ChatProvider({ children }: PropsWithChildren) { ); if (!failedMessage) return false; dismissFailedMessage(clientId); - return sendMessage(failedMessage.content, "user"); + const result = await sendMessage(failedMessage.content, "user"); + return result.ok; }; // Upload files to workspace @@ -720,6 +797,7 @@ export function ChatProvider({ children }: PropsWithChildren) { link.click(); link.remove(); URL.revokeObjectURL(url); + toast.success("Session JSONL downloaded"); } catch (error) { toast.error("Session download failed", { description: getErrorMessage(error), @@ -733,6 +811,7 @@ export function ChatProvider({ children }: PropsWithChildren) { value={{ messages, richMessages, + backgroundTasks, loading, sendMessage, retryFailedMessage, @@ -747,6 +826,8 @@ export function ChatProvider({ children }: PropsWithChildren) { nextReconnectAt, reconnectNow, downloadSession, + refreshBackgroundTasks, + getBackgroundTaskOutput, storageScope: agentAPIUrl, agentType, }} diff --git a/chat/src/components/chat.tsx b/chat/src/components/chat.tsx index bd2ab871..82e14a36 100644 --- a/chat/src/components/chat.tsx +++ b/chat/src/components/chat.tsx @@ -1,11 +1,14 @@ "use client"; import {useEffect, useState} from "react"; -import {RefreshCw} from "lucide-react"; +import {MessagesSquare, RefreshCw, TerminalSquare} from "lucide-react"; import {useChat} from "./chat-provider"; import MessageInput from "./message-input"; import MessageList from "./message-list"; +import {Explorer} from "./explorer"; +import {TerminalScreen} from "./terminal-screen"; import {Button} from "./ui/button"; +import {Tabs, TabsList, TabsTrigger} from "./ui/tabs"; export function Chat() { const [suggestedPrompt, setSuggestedPrompt] = useState(""); @@ -24,6 +27,7 @@ export function Chat() { reconnectNow, } = useChat(); const [reconnectSeconds, setReconnectSeconds] = useState(0); + const [view, setView] = useState<"chat" | "terminal">("chat"); useEffect(() => { if (!nextReconnectAt) { @@ -74,21 +78,44 @@ export function Chat() {
)} - { - dismissFailedMessage(clientId); - setSuggestedPrompt(content); - }} - onDismissMessage={dismissFailedMessage} - onRunTask={(content) => void sendMessage(content, "user")} - onStopTask={() => void sendMessage("\x1b", "raw")} - /> +
+ setView(value as typeof view)}> + + Chat + Terminal + + + { + setView("chat"); + window.requestAnimationFrame(() => + document.getElementById(`task-${number}`)?.scrollIntoView({ + behavior: "smooth", + block: "start", + }), + ); + }} + /> +
+ {view === "chat" ? ( + { + dismissFailedMessage(clientId); + setSuggestedPrompt(content); + }} + onDismissMessage={dismissFailedMessage} + onRunTask={(content) => void sendMessage(content, "user")} + onStopTask={() => void sendMessage("\x1b", "raw")} + /> + ) : ( + + )} { + test("joins terminal-wrapped URL segments", () => { + expect( + reconstructWrappedURLs( + "Open https://example.com/a/very/long/path?query=one&\nvalue=two", + ), + ).toEqual(["https://example.com/a/very/long/path?query=one&value=two"]); + }); + + test("does not join ordinary following prose", () => { + expect( + reconstructWrappedURLs("See https://example.com/docs\nThis is another line"), + ).toEqual(["https://example.com/docs"]); + }); +}); diff --git a/chat/src/components/explorer.tsx b/chat/src/components/explorer.tsx new file mode 100644 index 00000000..190cedaf --- /dev/null +++ b/chat/src/components/explorer.tsx @@ -0,0 +1,300 @@ +"use client"; + +import {useMemo, useState} from "react"; +import { + ExternalLink, + FileText, + FolderSearch, + Link as LinkIcon, + ListTree, + LoaderCircle, + RefreshCw, + SquareTerminal, +} from "lucide-react"; +import {toast} from "sonner"; +import {useChat} from "./chat-provider"; +import {Button} from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import {Tabs, TabsContent, TabsList, TabsTrigger} from "./ui/tabs"; + +interface ExplorerProps { + onNavigateTask: (number: number) => void; +} + +interface DiscoveredLink { + url: string; + task: number; +} + +const pathPattern = /(?:^|[\s"'(])((?:\/[\w.@+-]+)+\.[a-zA-Z0-9]{1,10})(?=$|[\s"'),:;])/g; + +export function Explorer({onNavigateTask}: ExplorerProps) { + const { + messages, + backgroundTasks, + refreshBackgroundTasks, + getBackgroundTaskOutput, + } = useChat(); + const [open, setOpen] = useState(false); + const [loadingTask, setLoadingTask] = useState(null); + const [outputs, setOutputs] = useState>({}); + const tasks = useMemo( + () => messages.filter((message) => message.role === "user"), + [messages], + ); + const links = useMemo(() => discoverLinks(messages), [messages]); + const files = useMemo(() => { + const found = new Map(); + let task = 0; + for (const message of messages) { + if (message.role === "user") task += 1; + for (const match of message.content.matchAll(pathPattern)) { + if (!found.has(match[1])) found.set(match[1], task); + } + } + for (const backgroundTask of backgroundTasks) { + if (backgroundTask.output_path && !found.has(backgroundTask.output_path)) { + found.set(backgroundTask.output_path, 0); + } + } + return [...found].map(([path, sourceTask]) => ({path, sourceTask})); + }, [backgroundTasks, messages]); + + const navigate = (number: number) => { + setOpen(false); + onNavigateTask(number); + }; + const loadOutput = async (id: string) => { + setLoadingTask(id); + try { + const output = await getBackgroundTaskOutput(id); + setOutputs((current) => ({ + ...current, + [id]: `${output.truncated ? "…showing tail…\n" : ""}${output.content}`, + })); + } catch { + toast.error("Background task output is unavailable"); + } finally { + setLoadingTask(null); + } + }; + + return ( + + + + + + + Session Explorer + + Background tasks, reconstructed links, files, and task navigation. + + + +
+ + Tasks + Links + Files + Index + +
+ +
+

+ {backgroundTasks.length} discovered background tasks +

+ +
+
+ {backgroundTasks.map((task) => ( +
+
+
+

{task.name}

+

+ {task.agent_type} · {task.id} +

+
+ +
+ {task.output_path && ( + <> +

+ {task.output_path} +

+ + + )} + {outputs[task.id] !== undefined && ( +
+                      {outputs[task.id] || "(empty output)"}
+                    
+ )} +
+ ))} + {backgroundTasks.length === 0 && } +
+
+ +
+ {links.map((link) => ( +
+ + + {link.url} + + {link.task > 0 && ( + + )} +
+ ))} + {links.length === 0 && } +
+
+ +
+ {files.map((file) => ( + + ))} + {files.length === 0 && } +
+
+ +
+ {tasks.map((task, index) => ( + + ))} +
+
+
+
+
+ ); +} + +function TaskStatus({status}: {status: string}) { + const color = + status === "running" + ? "bg-amber-500/15 text-amber-700 dark:text-amber-300" + : status === "completed" + ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300" + : status === "failed" + ? "bg-destructive/15 text-destructive" + : "bg-muted text-muted-foreground"; + return {status}; +} + +function Empty({text}: {text: string}) { + return

{text}

; +} + +function discoverLinks(messages: Array<{role: string; content: string}>) { + const found = new Map(); + let task = 0; + for (const message of messages) { + if (message.role === "user") task += 1; + for (const url of reconstructWrappedURLs(message.content)) { + if (!found.has(url)) found.set(url, {url, task}); + } + } + return [...found.values()]; +} + +export function reconstructWrappedURLs(content: string) { + const lines = content.split("\n"); + const urls: string[] = []; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + const starts = [...line.matchAll(/https?:\/\//g)].map((match) => match.index ?? 0); + for (const start of starts) { + let candidate = line.slice(start).match(/^\S+/)?.[0] ?? ""; + const reachesLineEnd = start + candidate.length === line.trimEnd().length; + let next = lineIndex + 1; + while ( + reachesLineEnd && + next < lines.length && + (lines[next - 1].trimEnd().length >= 72 || + /[/?#&=._~%+-]$/.test(candidate)) && + candidate.length > 0 && + /^[A-Za-z0-9/?#&=._~%+:@!,;()[\]-]+$/.test(lines[next].trim()) && + !/\s/.test(lines[next].trim()) + ) { + candidate += lines[next].trim(); + next += 1; + } + candidate = candidate.replace(/[),.;:!?]+$/u, ""); + try { + const parsed = new URL(candidate); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + urls.push(parsed.toString()); + } + } catch { + // Ignore text that only looked like a URL. + } + } + } + return [...new Set(urls)]; +} diff --git a/chat/src/components/message-input.tsx b/chat/src/components/message-input.tsx index 441226a4..ae7296be 100644 --- a/chat/src/components/message-input.tsx +++ b/chat/src/components/message-input.tsx @@ -28,7 +28,7 @@ import { RefreshCw, } from "lucide-react"; import {Tabs, TabsList, TabsTrigger} from "./ui/tabs"; -import type {ServerStatus} from "./chat-provider"; +import type {SendResult, ServerStatus} from "./chat-provider"; import TextareaAutosize from "react-textarea-autosize"; import {useChat} from "./chat-provider"; import {DragDrop} from "./drag-drop"; @@ -54,7 +54,7 @@ import { } from "./ui/dropdown-menu"; interface MessageInputProps { - onSendMessage: (message: string, type: "user" | "raw") => Promise; + onSendMessage: (message: string, type: "user" | "raw") => Promise; disabled?: boolean; serverStatus: ServerStatus; suggestedPrompt?: string; @@ -156,7 +156,7 @@ export default function MessageInput({ }: MessageInputProps) { const [message, setMessage] = useState(""); const [hydratedDraftKey, setHydratedDraftKey] = useState(null); - const [editingQueuedIndex, setEditingQueuedIndex] = useState(null); + const [editingQueuedID, setEditingQueuedID] = useState(null); const [editingQueuedMessage, setEditingQueuedMessage] = useState(""); const [inputMode, setInputMode] = useState<"text" | "control">("text"); const [sentChars, setSentChars] = useState([]); @@ -282,24 +282,31 @@ export default function MessageInput({ window.requestAnimationFrame(() => textareaRef.current?.focus()); }, [onSuggestedPromptApplied, suggestedPrompt]); - const startEditingQueuedMessage = (index: number) => { - setEditingQueuedIndex(index); - setEditingQueuedMessage(queuedMessages[index].content); + const startEditingQueuedMessage = (id: number, content: string) => { + setEditingQueuedID(id); + setEditingQueuedMessage(content); }; const cancelEditingQueuedMessage = () => { - setEditingQueuedIndex(null); + setEditingQueuedID(null); setEditingQueuedMessage(""); }; + useEffect(() => { + if ( + editingQueuedID !== null && + !queuedMessages.some((queuedMessage) => queuedMessage.id === editingQueuedID) + ) { + setEditingQueuedID(null); + setEditingQueuedMessage(""); + } + }, [editingQueuedID, queuedMessages]); + const saveEditingQueuedMessage = async () => { - if (editingQueuedIndex === null || !editingQueuedMessage.trim()) return; + if (editingQueuedID === null || !editingQueuedMessage.trim()) return; try { - await updateQueuedMessage( - queuedMessages[editingQueuedIndex].id, - editingQueuedMessage, - ); + await updateQueuedMessage(editingQueuedID, editingQueuedMessage); cancelEditingQueuedMessage(); } catch (error) { toast.error("Failed to update queued task", { @@ -308,17 +315,10 @@ export default function MessageInput({ } }; - const removeQueuedMessage = async (index: number) => { + const removeQueuedMessage = async (id: number) => { try { - await deleteQueuedMessage(queuedMessages[index].id); - setEditingQueuedIndex((currentIndex) => { - if (currentIndex === null) return null; - if (currentIndex === index) { - setEditingQueuedMessage(""); - return null; - } - return currentIndex > index ? currentIndex - 1 : currentIndex; - }); + await deleteQueuedMessage(id); + if (editingQueuedID === id) cancelEditingQueuedMessage(); } catch (error) { toast.error("Failed to delete queued task", { description: getErrorMessage(error), @@ -417,19 +417,13 @@ export default function MessageInput({ } }; - const handleSubmit = (e: FormEvent) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (message.trim() && !disabled) { - if (serverStatus === "running") { - onSendMessage(message, "user"); - toast.success("Task queued", { - description: `Queue position ${queuedMessages.length + 1}`, - }); - } else if (serverStatus === "stable") { - onSendMessage(message, "user"); - } else { - return; - } + if (serverStatus !== "running" && serverStatus !== "stable") return; + const result = await onSendMessage(message, "user"); + if (!result.ok) return; + if (result.queued) toast.success("Task queued"); setMessage(""); setAttachments((previous) => previous.filter((attachment) => attachment.status !== "completed"), @@ -796,7 +790,7 @@ export default function MessageInput({ )} {inputMode === "text" && queuedMessages.length > 0 && ( -
+
@@ -811,7 +805,7 @@ export default function MessageInput({ key={queuedMessage.id} className="flex w-full items-center gap-1 rounded-md border bg-background py-1 pl-2.5 pr-1 text-xs" > - {editingQueuedIndex === index ? ( + {editingQueuedID === queuedMessage.id ? ( ) : ( @@ -839,25 +833,28 @@ export default function MessageInput({ variant="ghost" className="size-6 shrink-0 text-muted-foreground" onClick={() => - editingQueuedIndex === index + editingQueuedID === queuedMessage.id ? saveEditingQueuedMessage() - : startEditingQueuedMessage(index) + : startEditingQueuedMessage( + queuedMessage.id, + queuedMessage.content, + ) } disabled={ - editingQueuedIndex === index && + editingQueuedID === queuedMessage.id && !editingQueuedMessage.trim() } title={ - editingQueuedIndex === index + editingQueuedID === queuedMessage.id ? "Save queued task" : "Edit queued task" } > - {editingQueuedIndex === index + {editingQueuedID === queuedMessage.id ? : } - {editingQueuedIndex === index ? "Save" : "Edit"} queued task + {editingQueuedID === queuedMessage.id ? "Save" : "Edit"} queued task {taskQuery && filteredTasks.length > 0 && (
diff --git a/chat/src/components/terminal-screen.tsx b/chat/src/components/terminal-screen.tsx new file mode 100644 index 00000000..71bc98b9 --- /dev/null +++ b/chat/src/components/terminal-screen.tsx @@ -0,0 +1,30 @@ +"use client"; + +import {useEffect, useState} from "react"; +import {useAgentAPIUrl} from "./chat-provider"; + +export function TerminalScreen() { + const agentAPIUrl = useAgentAPIUrl(); + const [screen, setScreen] = useState(""); + + useEffect(() => { + const eventSource = new EventSource(`${agentAPIUrl}/internal/screen`); + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as {screen?: string}; + if (typeof data.screen === "string") setScreen(data.screen); + } catch { + // Keep the last valid terminal snapshot. + } + }; + return () => eventSource.close(); + }, [agentAPIUrl]); + + return ( +
+
+        {screen || "Waiting for terminal output…"}
+      
+
+ ); +} diff --git a/chat/src/stories/message-input.stories.tsx b/chat/src/stories/message-input.stories.tsx index 942f3a03..20dfe1d1 100644 --- a/chat/src/stories/message-input.stories.tsx +++ b/chat/src/stories/message-input.stories.tsx @@ -15,7 +15,7 @@ export default meta; type Story = StoryObj; const defaultArgs = { - onSendMessage: async () => true, + onSendMessage: async () => ({ok: true, queued: false}), }; export const ServerStatusStable: Story = { From f08a3f3e29605be3147dcd9dd31c1546709b3691 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 13:37:13 +0000 Subject: [PATCH 028/122] fix(chat): remove run again action --- chat/src/components/chat.tsx | 1 - chat/src/components/message-list.tsx | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/chat/src/components/chat.tsx b/chat/src/components/chat.tsx index 82e14a36..65c3726a 100644 --- a/chat/src/components/chat.tsx +++ b/chat/src/components/chat.tsx @@ -110,7 +110,6 @@ export function Chat() { setSuggestedPrompt(content); }} onDismissMessage={dismissFailedMessage} - onRunTask={(content) => void sendMessage(content, "user")} onStopTask={() => void sendMessage("\x1b", "raw")} /> ) : ( diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index b4e5e01c..451d545a 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -58,7 +58,6 @@ interface MessageListProps { onRetryMessage: (clientId: string) => Promise; onEditMessage: (clientId: string, content: string) => void; onDismissMessage: (clientId: string) => void; - onRunTask: (content: string) => void; onStopTask: () => void; } @@ -124,7 +123,6 @@ export default function MessageList({ onRetryMessage, onEditMessage, onDismissMessage, - onRunTask, onStopTask, }: MessageListProps) { const [scrollArea, setScrollArea] = useState(null); @@ -524,7 +522,6 @@ export default function MessageList({ onRetryMessage={onRetryMessage} onEditMessage={onEditMessage} onDismissMessage={onDismissMessage} - onRunTask={onRunTask} onStopTask={onStopTask} searchQuery={taskQuery} searchResultIndex={searchResultIndex} @@ -792,7 +789,6 @@ function TaskGroup({ onRetryMessage, onEditMessage, onDismissMessage, - onRunTask, onStopTask, searchQuery, searchResultIndex, @@ -804,7 +800,6 @@ function TaskGroup({ onRetryMessage: (clientId: string) => Promise; onEditMessage: (clientId: string, content: string) => void; onDismissMessage: (clientId: string) => void; - onRunTask: (content: string) => void; onStopTask: () => void; searchQuery: string; searchResultIndex: number; @@ -934,12 +929,6 @@ function TaskGroup({ - onRunTask(task.prompt.content)} - > - - Run again - void copyTask()}> Copy task and output From 40962f6169510aaea7f2664e15ebb5429ade27b4 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 13:39:12 +0000 Subject: [PATCH 029/122] fix(chat): wrap long agent output lines --- chat/src/components/processed-message.test.tsx | 5 +++-- chat/src/components/processed-message.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/chat/src/components/processed-message.test.tsx b/chat/src/components/processed-message.test.tsx index 56a21cf1..ba91c877 100644 --- a/chat/src/components/processed-message.test.tsx +++ b/chat/src/components/processed-message.test.tsx @@ -35,8 +35,9 @@ describe("ProcessedMessage links", () => { test("renders agent output as preformatted terminal text", () => { const html = render("first line\nsecond line"); - expect(html).toContain("whitespace-pre"); - expect(html).toContain("overflow-x-auto"); + expect(html).toContain("whitespace-pre-wrap"); + expect(html).toContain("[overflow-wrap:anywhere]"); + expect(html).not.toContain("overflow-x-auto"); expect(html).toContain("first line\nsecond line"); expect(html).not.toContain(" +
{searchQuery ? highlightTerminalText(messageContent, searchQuery) : linkifyTerminalText(messageContent)} From bf97314c7716964090a344653541d9a7102e12b0 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 13:41:32 +0000 Subject: [PATCH 030/122] fix(chat): simplify header controls and button cursors --- chat/src/app/globals.css | 5 +++ chat/src/components/chat.tsx | 46 +++++++++---------------- chat/src/components/terminal-screen.tsx | 30 ---------------- chat/src/components/ui/button.tsx | 2 +- 4 files changed, 22 insertions(+), 61 deletions(-) delete mode 100644 chat/src/components/terminal-screen.tsx diff --git a/chat/src/app/globals.css b/chat/src/app/globals.css index 98dc6b24..c177d88d 100644 --- a/chat/src/app/globals.css +++ b/chat/src/app/globals.css @@ -130,6 +130,11 @@ -webkit-tap-highlight-color: transparent; } + button:not(:disabled), + [role="button"]:not([aria-disabled="true"]) { + cursor: pointer; + } + ::selection { @apply bg-primary/15; } diff --git a/chat/src/components/chat.tsx b/chat/src/components/chat.tsx index 65c3726a..139dcfea 100644 --- a/chat/src/components/chat.tsx +++ b/chat/src/components/chat.tsx @@ -1,14 +1,12 @@ "use client"; import {useEffect, useState} from "react"; -import {MessagesSquare, RefreshCw, TerminalSquare} from "lucide-react"; +import {RefreshCw} from "lucide-react"; import {useChat} from "./chat-provider"; import MessageInput from "./message-input"; import MessageList from "./message-list"; import {Explorer} from "./explorer"; -import {TerminalScreen} from "./terminal-screen"; import {Button} from "./ui/button"; -import {Tabs, TabsList, TabsTrigger} from "./ui/tabs"; export function Chat() { const [suggestedPrompt, setSuggestedPrompt] = useState(""); @@ -27,7 +25,6 @@ export function Chat() { reconnectNow, } = useChat(); const [reconnectSeconds, setReconnectSeconds] = useState(0); - const [view, setView] = useState<"chat" | "terminal">("chat"); useEffect(() => { if (!nextReconnectAt) { @@ -78,16 +75,9 @@ export function Chat() {
)} -
- setView(value as typeof view)}> - - Chat - Terminal - - +
{ - setView("chat"); window.requestAnimationFrame(() => document.getElementById(`task-${number}`)?.scrollIntoView({ behavior: "smooth", @@ -97,24 +87,20 @@ export function Chat() { }} />
- {view === "chat" ? ( - { - dismissFailedMessage(clientId); - setSuggestedPrompt(content); - }} - onDismissMessage={dismissFailedMessage} - onStopTask={() => void sendMessage("\x1b", "raw")} - /> - ) : ( - - )} + { + dismissFailedMessage(clientId); + setSuggestedPrompt(content); + }} + onDismissMessage={dismissFailedMessage} + onStopTask={() => void sendMessage("\x1b", "raw")} + /> { - const eventSource = new EventSource(`${agentAPIUrl}/internal/screen`); - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as {screen?: string}; - if (typeof data.screen === "string") setScreen(data.screen); - } catch { - // Keep the last valid terminal snapshot. - } - }; - return () => eventSource.close(); - }, [agentAPIUrl]); - - return ( -
-
-        {screen || "Waiting for terminal output…"}
-      
-
- ); -} diff --git a/chat/src/components/ui/button.tsx b/chat/src/components/ui/button.tsx index a2df8dce..8372cea0 100644 --- a/chat/src/components/ui/button.tsx +++ b/chat/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { From 58f360c2b2c1152f119225ec8ad4f848e7030009 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 13:43:38 +0000 Subject: [PATCH 031/122] fix(chat): preserve explorer tab selection --- chat/src/components/explorer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/chat/src/components/explorer.tsx b/chat/src/components/explorer.tsx index 190cedaf..538343e6 100644 --- a/chat/src/components/explorer.tsx +++ b/chat/src/components/explorer.tsx @@ -43,6 +43,7 @@ export function Explorer({onNavigateTask}: ExplorerProps) { getBackgroundTaskOutput, } = useChat(); const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState("background"); const [loadingTask, setLoadingTask] = useState(null); const [outputs, setOutputs] = useState>({}); const tasks = useMemo( @@ -102,7 +103,11 @@ export function Explorer({onNavigateTask}: ExplorerProps) { Background tasks, reconstructed links, files, and task navigation. - +
Tasks From e9c67098ee8f51beb8c743b7aa21dca660cd5e5d Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 13:45:48 +0000 Subject: [PATCH 032/122] fix(chat): render tables in markdown preview --- .../src/components/processed-message.test.tsx | 13 +++++++++++ chat/src/components/processed-message.tsx | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/chat/src/components/processed-message.test.tsx b/chat/src/components/processed-message.test.tsx index ba91c877..93b5a22f 100644 --- a/chat/src/components/processed-message.test.tsx +++ b/chat/src/components/processed-message.test.tsx @@ -61,6 +61,19 @@ describe("ProcessedMessage links", () => { expect(html).toContain("Completed"); }); + test("renders GFM tables with readable table structure", () => { + const html = render( + "| Name | Status |\n| --- | --- |\n| Build | Passed |", + true, + ); + expect(html).toContain("Build"); + expect(html).toContain("overflow-x-auto"); + }); + test("highlights case-insensitive raw output search matches", () => { const html = renderToStaticMarkup(

{children}

, + table: ({children}) => ( +
+ + {children} +
+
+ ), + thead: ({children}) => ( + {children} + ), + th: ({children}) => ( + + {children} + + ), + td: ({children}) => ( + + {children} + + ), + tr: ({children}) => ( + {children} + ), pre: ({children}) => (
               {children}

From 0e9e8e2ee7d12b039341b048ca6cab258924134b Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:50:26 +0000
Subject: [PATCH 033/122] fix(httpapi): prevent double-close panic in
 EventEmitter

Guard unsubscribeInner against double-close by checking map membership
before closing. When a subscriber's channel buffer fills, notifyChannels
removes it, and the SSE handler's defer Unsubscribe would close(nil),
causing a panic.

Co-Authored-By: Claude 
---
 lib/httpapi/events.go | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/lib/httpapi/events.go b/lib/httpapi/events.go
index 4e2812cc..f7134b19 100644
--- a/lib/httpapi/events.go
+++ b/lib/httpapi/events.go
@@ -361,7 +361,11 @@ func (e *EventEmitter) Subscribe() (int, <-chan Event, []Event) {
 
 // Assumes the caller holds the lock.
 func (e *EventEmitter) unsubscribeInner(chanId int) {
-	close(e.chans[chanId])
+	ch, ok := e.chans[chanId]
+	if !ok {
+		return
+	}
+	close(ch)
 	delete(e.chans, chanId)
 }
 

From d3a7d20d5a7fbee28a300c4b350c2ad9de58a43d Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:51:17 +0000
Subject: [PATCH 034/122] fix(httpapi): eliminate TOCTOU race in getMessages

Call Messages() once and store in a local variable instead of calling it
twice (once for len, once for range). Between the two calls another
goroutine could add a message, causing an index-out-of-bounds panic.

Co-Authored-By: Claude 
---
 lib/httpapi/server.go | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go
index be9784ff..c3d54dcc 100644
--- a/lib/httpapi/server.go
+++ b/lib/httpapi/server.go
@@ -548,8 +548,9 @@ func (s *Server) getMessages(ctx context.Context, input *struct{}) (*MessagesRes
 	defer s.mu.RUnlock()
 
 	resp := &MessagesResponse{}
-	resp.Body.Messages = make([]Message, len(s.conversation.Messages()))
-	for i, msg := range s.conversation.Messages() {
+	msgs := s.conversation.Messages()
+	resp.Body.Messages = make([]Message, len(msgs))
+	for i, msg := range msgs {
 		resp.Body.Messages[i] = Message{
 			Id:      msg.Id,
 			Role:    msg.Role,

From 5bf48602e7b064283f3aea2a45e7558105438b4a Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:52:05 +0000
Subject: [PATCH 035/122] fix(termexec): fix data race, PTY leak, and
 double-Wait

- ReadScreen fallback now holds RLock to prevent data race with the
  terminal writer goroutine.
- Close() uses defer to always close PTY even if SIGINT fails, preventing
  file descriptor leaks when the process has already exited.
- StartProcess closes PTY on StartProcessInTerminal failure.
- Wait() and Close() share a sync.Once to prevent calling
  os.Process.Wait() more than once (undefined behavior on Linux).

Co-Authored-By: Claude 
---
 lib/termexec/termexec.go | 75 ++++++++++++++++++++++++++--------------
 1 file changed, 49 insertions(+), 26 deletions(-)

diff --git a/lib/termexec/termexec.go b/lib/termexec/termexec.go
index 739839c9..8d88366a 100644
--- a/lib/termexec/termexec.go
+++ b/lib/termexec/termexec.go
@@ -24,6 +24,11 @@ type Process struct {
 	screenUpdateLock sync.RWMutex
 	lastScreenUpdate time.Time
 	clock            quartz.Clock
+
+	waitOnce   sync.Once
+	waitState  *os.ProcessState
+	waitErr    error
+	waitDone   chan struct{}
 }
 
 type StartProcessConfig struct {
@@ -50,10 +55,11 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
 	// escape sequences.
 	execCmd.Env = append(os.Environ(), "TERM=vt100")
 	if err := xp.StartProcessInTerminal(execCmd); err != nil {
+		xp.Close()
 		return nil, err
 	}
 
-	process := &Process{xp: xp, execCmd: execCmd, clock: clock}
+	process := &Process{xp: xp, execCmd: execCmd, clock: clock, waitDone: make(chan struct{})}
 
 	go func() {
 		// HACK: Working around xpty concurrency limitations
@@ -143,7 +149,10 @@ func (p *Process) ReadScreen() string {
 		<-t.C
 		t.Stop()
 	}
-	return stripWidePadding(p.xp.State.String())
+	p.screenUpdateLock.RLock()
+	state := p.xp.State.String()
+	p.screenUpdateLock.RUnlock()
+	return stripWidePadding(state)
 }
 
 // Write sends input to the process via the pseudo terminal.
@@ -155,49 +164,63 @@ func (p *Process) Write(data []byte) (int, error) {
 // does not exit after the timeout. It then closes the pseudo terminal.
 func (p *Process) Close(logger *slog.Logger, timeout time.Duration) error {
 	logger.Info("Closing process")
+	// Always close the PTY, even if signaling fails.
+	defer func() {
+		if err := p.xp.Close(); err != nil {
+			logger.Error("Failed to close pseudo terminal", "error", err)
+		}
+	}()
+
 	if err := p.execCmd.Process.Signal(os.Interrupt); err != nil {
-		return xerrors.Errorf("failed to send SIGINT to process: %w", err)
+		// If the process already exited, SIGINT fails — that's fine,
+		// just ensure we still close the PTY (handled by defer above).
+		if !errors.Is(err, os.ErrProcessDone) {
+			logger.Error("Failed to send SIGINT to process", "error", err)
+		}
+		return nil
 	}
 
-	exited := make(chan error, 1)
-	go func() {
-		_, err := p.execCmd.Process.Wait()
-		exited <- err
-		close(exited)
-	}()
+	// Wait for the process to exit or force-kill after timeout.
+	// Use doWait to avoid racing with a concurrent Wait() call.
+	go p.doWait()
 
 	timeoutTimer := p.clock.NewTimer(timeout)
 	defer timeoutTimer.Stop()
-	var exitErr error
 	select {
 	case <-timeoutTimer.C:
 		if err := p.execCmd.Process.Kill(); err != nil {
-			exitErr = xerrors.Errorf("failed to forcefully kill the process: %w", err)
+			return xerrors.Errorf("failed to forcefully kill the process: %w", err)
 		}
-		// don't wait for the process to exit to avoid hanging indefinitely
-		// if the process never exits
-	case err := <-exited:
-		var pathErr *os.SyscallError
-		// ECHILD is expected if the process has already exited
-		if err != nil && !(errors.As(err, &pathErr) && errors.Is(pathErr.Err, syscall.ECHILD)) {
-			exitErr = xerrors.Errorf("process exited with error: %w", err)
+		// Don't wait for the process to exit to avoid hanging indefinitely.
+	case <-p.waitDone:
+		if p.waitErr != nil {
+			var pathErr *os.SyscallError
+			// ECHILD is expected if the process has already exited.
+			if !(errors.As(p.waitErr, &pathErr) && errors.Is(pathErr.Err, syscall.ECHILD)) {
+				return xerrors.Errorf("process exited with error: %w", p.waitErr)
+			}
 		}
 	}
-	if err := p.xp.Close(); err != nil {
-		return xerrors.Errorf("failed to close pseudo terminal: %w, exitErr: %w", err, exitErr)
-	}
-	return exitErr
+	return nil
 }
 
 var ErrNonZeroExitCode = xerrors.New("non-zero exit code")
 
+// doWait performs the actual os.Process.Wait exactly once, safe for concurrent callers.
+func (p *Process) doWait() {
+	p.waitOnce.Do(func() {
+		p.waitState, p.waitErr = p.execCmd.Process.Wait()
+		close(p.waitDone)
+	})
+}
+
 // Wait waits for the process to exit.
 func (p *Process) Wait() error {
-	state, err := p.execCmd.Process.Wait()
-	if err != nil {
-		return xerrors.Errorf("process exited with error: %w", err)
+	p.doWait()
+	if p.waitErr != nil {
+		return xerrors.Errorf("process exited with error: %w", p.waitErr)
 	}
-	if state.ExitCode() != 0 {
+	if p.waitState != nil && p.waitState.ExitCode() != 0 {
 		return ErrNonZeroExitCode
 	}
 	return nil

From 184aec7d7bee9c93803d0bb425636a414a571940 Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:52:16 +0000
Subject: [PATCH 036/122] fix(server): guard nil process in ACP shutdown path

In ACP mode, process is nil. The shutdown default branch called
process.Close() without checking, causing a nil pointer dereference.

Co-Authored-By: Claude 
---
 cmd/server/server.go | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/cmd/server/server.go b/cmd/server/server.go
index 4387af43..179e2468 100644
--- a/cmd/server/server.go
+++ b/cmd/server/server.go
@@ -302,8 +302,10 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
 		}
 	default:
 		// Close the process
-		if err := process.Close(logger, 5*time.Second); err != nil {
-			logger.Error("Failed to close process cleanly", "error", err)
+		if process != nil {
+			if err := process.Close(logger, 5*time.Second); err != nil {
+				logger.Error("Failed to close process cleanly", "error", err)
+			}
 		}
 	}
 	return nil

From 812bf6354bed9ea7c9d25139f52f3f476d8b8f74 Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:52:24 +0000
Subject: [PATCH 037/122] fix(screentracker): fix Opencode off-by-one in
 screenDiff

The loop index was relative to the sub-slice but used as an absolute
offset, causing header lines to leak into the diff output for the
Opencode agent type.

Co-Authored-By: Claude 
---
 lib/screentracker/diff.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/screentracker/diff.go b/lib/screentracker/diff.go
index ffece548..e8e9fdda 100644
--- a/lib/screentracker/diff.go
+++ b/lib/screentracker/diff.go
@@ -31,7 +31,7 @@ func screenDiff(oldScreen, newScreen string, agentType msgfmt.AgentType) string
 	firstNonMatchingLine := len(newLines)
 	for i, line := range newLines[dynamicHeaderEnd+1:] {
 		if !oldLinesMap[line] {
-			firstNonMatchingLine = i
+			firstNonMatchingLine = i + dynamicHeaderEnd + 1
 			break
 		}
 	}

From a7220b1c31e5eadd84867e6b97fa21f550aa14e9 Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:52:36 +0000
Subject: [PATCH 038/122] fix(jsonlwatcher): flush completed messages after
 each poll cycle

Add FlushCompleted() to the LineParser interface, which only finalizes
pending messages that have a terminal stop_reason (e.g. end_turn).
The watcher calls this after reading all available lines, so the last
assistant message is emitted immediately instead of staying in pending
until the next user message arrives.

This fixes the bug where Claude's final text after tool calls would not
appear in the chat UI until the user sent another message.

Co-Authored-By: Claude 
---
 lib/jsonlwatcher/claude_parser.go | 17 ++++++++++++++++-
 lib/jsonlwatcher/codex_parser.go  |  7 +++++++
 lib/jsonlwatcher/types.go         |  7 ++++++-
 lib/jsonlwatcher/watcher.go       | 10 +++++++++-
 4 files changed, 38 insertions(+), 3 deletions(-)

diff --git a/lib/jsonlwatcher/claude_parser.go b/lib/jsonlwatcher/claude_parser.go
index 685bfe37..f16c80af 100644
--- a/lib/jsonlwatcher/claude_parser.go
+++ b/lib/jsonlwatcher/claude_parser.go
@@ -40,7 +40,22 @@ func (p *ClaudeParser) ParseLine(line []byte) ([]RichMessage, error) {
 	}
 }
 
-// Flush finalizes any pending assistant messages.
+// FlushCompleted finalizes only pending messages that have a terminal stop_reason.
+func (p *ClaudeParser) FlushCompleted() []RichMessage {
+	var result []RichMessage
+	for id, msg := range p.pending {
+		if msg.StopReason != "" {
+			result = append(result, *msg)
+			delete(p.pending, id)
+			if p.lastPendingID == id {
+				p.lastPendingID = ""
+			}
+		}
+	}
+	return result
+}
+
+// Flush finalizes all pending assistant messages regardless of state.
 func (p *ClaudeParser) Flush() []RichMessage {
 	return p.finalizePending()
 }
diff --git a/lib/jsonlwatcher/codex_parser.go b/lib/jsonlwatcher/codex_parser.go
index a640c131..ec1a264e 100644
--- a/lib/jsonlwatcher/codex_parser.go
+++ b/lib/jsonlwatcher/codex_parser.go
@@ -102,6 +102,13 @@ func (p *CodexParser) ParseLine(line []byte) ([]RichMessage, error) {
 	}
 }
 
+// FlushCompleted finalizes only completed pending turns.
+// Codex turns are always complete when in pending state (each event
+// is self-contained), so this behaves the same as Flush.
+func (p *CodexParser) FlushCompleted() []RichMessage {
+	return p.finalizeTurn()
+}
+
 // Flush finalizes any pending turn.
 func (p *CodexParser) Flush() []RichMessage {
 	return p.finalizeTurn()
diff --git a/lib/jsonlwatcher/types.go b/lib/jsonlwatcher/types.go
index ad96142e..1ecfd4be 100644
--- a/lib/jsonlwatcher/types.go
+++ b/lib/jsonlwatcher/types.go
@@ -80,7 +80,12 @@ type LineParser interface {
 	// ParseLine processes a single JSONL line.
 	// Returns completed (fully assembled) messages ready to emit.
 	ParseLine(line []byte) (completed []RichMessage, err error)
-	// Flush finalizes any pending incomplete messages (e.g., on shutdown).
+	// FlushCompleted finalizes only pending messages that have a terminal
+	// stop_reason (e.g. "end_turn", "max_tokens"). Messages still being
+	// assembled (no stop_reason yet) are left in pending. This is safe to
+	// call on every poll cycle without risk of splitting an in-progress turn.
+	FlushCompleted() []RichMessage
+	// Flush finalizes all pending messages regardless of state (e.g., on shutdown).
 	Flush() []RichMessage
 }
 
diff --git a/lib/jsonlwatcher/watcher.go b/lib/jsonlwatcher/watcher.go
index 5335aff7..e94b1527 100644
--- a/lib/jsonlwatcher/watcher.go
+++ b/lib/jsonlwatcher/watcher.go
@@ -143,9 +143,17 @@ func (w *Watcher) tailFile(ctx context.Context, path string) {
 			w.processLine(line)
 		}
 
+		// Flush only completed messages (those with a terminal stop_reason).
+		// Without this, the last assistant message (with stop_reason="end_turn")
+		// stays in the parser's pending state indefinitely because no subsequent
+		// JSONL line arrives to trigger finalization. We use FlushCompleted
+		// instead of Flush to avoid emitting partial messages that are still
+		// being assembled across multiple JSONL lines.
+		w.emit(w.parser.FlushCompleted())
+
 		select {
 		case <-ctx.Done():
-			// Flush any pending messages from the parser
+			// Flush all pending messages on shutdown, even incomplete ones.
 			w.emit(w.parser.Flush())
 			return
 		case <-pollTicker.C:

From 36ffb564acf3ce955fa705303fd021ff31fdacd9 Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:53:05 +0000
Subject: [PATCH 039/122] feat(httpapi): add title and background-tasks
 endpoints

- GET /title returns a human-readable session title derived from the
  latest user task and agent status.
- GET /background-tasks lists background tasks discovered from
  structured tool calls in rich messages.
- GET /background-tasks/{id}/output returns the tail of a background
  task output file.

Co-Authored-By: Claude 
---
 lib/httpapi/background_tasks.go      | 334 +++++++++++++++++++++++++++
 lib/httpapi/background_tasks_test.go | 109 +++++++++
 lib/httpapi/models.go                |  45 ++++
 lib/httpapi/server.go                |   2 +
 openapi.json                         | 256 ++++++++++++++++++++
 5 files changed, 746 insertions(+)
 create mode 100644 lib/httpapi/background_tasks.go
 create mode 100644 lib/httpapi/background_tasks_test.go

diff --git a/lib/httpapi/background_tasks.go b/lib/httpapi/background_tasks.go
new file mode 100644
index 00000000..6d0b1f4d
--- /dev/null
+++ b/lib/httpapi/background_tasks.go
@@ -0,0 +1,334 @@
+package httpapi
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"io"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/coder/agentapi/lib/jsonlwatcher"
+	"github.com/danielgtaylor/huma/v2"
+)
+
+var (
+	backgroundIDPattern = regexp.MustCompile(`(?i)(?:task|shell|session|cell)(?:\s+id)?["':=\s]+([a-z0-9][a-z0-9_-]*)`)
+	outputPathPattern   = regexp.MustCompile(`(?:^|[\s"'=:])(/[^\s"']+\.(?:out|output))(?:\s|$)`)
+	redirectPathPattern = regexp.MustCompile(`(?:^|[\s])>{1,2}\s*([^\s"';&]+\.(?:out|output))(?:\s|$)`)
+)
+
+type discoveredTool struct {
+	name      string
+	input     map[string]any
+	result    string
+	status    string
+	isError   bool
+	timestamp time.Time
+	updatedAt time.Time
+}
+
+func (s *Server) getBackgroundTasks(ctx context.Context, input *struct{}) (*BackgroundTasksResponse, error) {
+	resp := &BackgroundTasksResponse{}
+	resp.Body.Tasks = s.backgroundTasks()
+	return resp, nil
+}
+
+func (s *Server) getBackgroundTaskOutput(ctx context.Context, input *BackgroundTaskOutputRequest) (*BackgroundTaskOutputResponse, error) {
+	tasks := s.backgroundTasks()
+	var task *BackgroundTask
+	for i := range tasks {
+		if tasks[i].ID == input.ID {
+			task = &tasks[i]
+			break
+		}
+	}
+	if task == nil {
+		return nil, huma.Error404NotFound("background task output was not found")
+	}
+
+	if task.OutputPath == "" {
+		content := []byte(task.Output)
+		tail := input.Tail
+		if tail <= 0 {
+			tail = 128 * 1024
+		}
+		start := max(0, len(content)-tail)
+		resp := &BackgroundTaskOutputResponse{}
+		resp.Body.TaskID = task.ID
+		resp.Body.Content = string(content[start:])
+		resp.Body.Size = int64(len(content))
+		resp.Body.Truncated = start > 0
+		return resp, nil
+	}
+
+	path, err := s.safeBackgroundOutputPath(task.OutputPath)
+	if err != nil {
+		return nil, huma.Error403Forbidden("background task output path is not readable")
+	}
+	file, err := os.Open(path)
+	if errors.Is(err, os.ErrNotExist) {
+		return nil, huma.Error404NotFound("background task output file does not exist")
+	}
+	if err != nil {
+		return nil, huma.Error500InternalServerError("failed to open background task output")
+	}
+	defer file.Close()
+
+	info, err := file.Stat()
+	if err != nil {
+		return nil, huma.Error500InternalServerError("failed to inspect background task output")
+	}
+	tail := int64(input.Tail)
+	if tail <= 0 {
+		tail = 128 * 1024
+	}
+	start := max(int64(0), info.Size()-tail)
+	if _, err := file.Seek(start, io.SeekStart); err != nil {
+		return nil, huma.Error500InternalServerError("failed to seek background task output")
+	}
+	content, err := io.ReadAll(io.LimitReader(file, tail))
+	if err != nil {
+		return nil, huma.Error500InternalServerError("failed to read background task output")
+	}
+
+	resp := &BackgroundTaskOutputResponse{}
+	resp.Body.TaskID = task.ID
+	resp.Body.Path = task.OutputPath
+	resp.Body.Content = string(content)
+	resp.Body.Size = info.Size()
+	resp.Body.Truncated = start > 0
+	return resp, nil
+}
+
+func (s *Server) backgroundTasks() []BackgroundTask {
+	tasks := discoverBackgroundTasks(s.emitter.RichMessages(), string(s.agentType))
+	for i := range tasks {
+		if tasks[i].OutputPath != "" && !filepath.IsAbs(tasks[i].OutputPath) && s.cwd != "" {
+			tasks[i].OutputPath = filepath.Join(s.cwd, tasks[i].OutputPath)
+		}
+	}
+	return tasks
+}
+
+func (s *Server) safeBackgroundOutputPath(discovered string) (string, error) {
+	cleaned := filepath.Clean(discovered)
+	extension := strings.ToLower(filepath.Ext(cleaned))
+	if extension != ".out" && extension != ".output" {
+		return "", errors.New("unsupported output extension")
+	}
+	info, err := os.Lstat(cleaned)
+	if err != nil || !info.Mode().IsRegular() {
+		return "", errors.New("output is not a regular file")
+	}
+	allowedRoots := []string{s.cwd, os.TempDir()}
+	for _, root := range allowedRoots {
+		if root == "" {
+			continue
+		}
+		absoluteRoot, err := filepath.Abs(root)
+		if err != nil {
+			continue
+		}
+		relative, err := filepath.Rel(absoluteRoot, cleaned)
+		if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
+			return cleaned, nil
+		}
+	}
+	return "", errors.New("output is outside allowed roots")
+}
+
+func discoverBackgroundTasks(messages []jsonlwatcher.RichMessage, agentType string) []BackgroundTask {
+	tools := map[string]*discoveredTool{}
+	order := []string{}
+	for _, message := range messages {
+		for _, block := range message.Content {
+			switch block.Type {
+			case "tool_use":
+				input := map[string]any{}
+				_ = json.Unmarshal(block.ToolInput, &input)
+				tools[block.ToolUseID] = &discoveredTool{
+					name: block.ToolName, input: input, status: block.Status,
+					timestamp: message.Timestamp, updatedAt: message.Timestamp,
+				}
+				order = append(order, block.ToolUseID)
+			case "tool_result":
+				if tool := tools[block.ToolUseID]; tool != nil {
+					tool.result = block.Text
+					tool.updatedAt = message.Timestamp
+					tool.status = block.Status
+					tool.isError = block.IsError != nil && *block.IsError
+				}
+			}
+		}
+	}
+
+	tasks := []BackgroundTask{}
+	byRuntimeID := map[string]int{}
+	for _, toolUseID := range order {
+		tool := tools[toolUseID]
+		if tool == nil {
+			continue
+		}
+		name := normalizeToolName(tool.name)
+		if isBackgroundStart(name, tool) {
+			runtimeID := firstString(tool.input, "task_id", "session_id", "shell_id")
+			if runtimeID == "" {
+				runtimeID = extractBackgroundID(tool.result)
+			}
+			if runtimeID == "" {
+				runtimeID = toolUseID
+			}
+			outputPath := extractOutputPath(tool.result)
+			if outputPath == "" {
+				outputPath = extractRedirectPath(firstString(tool.input, "cmd", "command"))
+			}
+			task := BackgroundTask{
+				ID: runtimeID, Name: backgroundTaskName(tool), Status: backgroundStatus(tool, true),
+				AgentType: agentType, ToolUseID: toolUseID, OutputPath: outputPath,
+				StartedAt: tool.timestamp, UpdatedAt: tool.updatedAt,
+			}
+			tasks = append(tasks, task)
+			byRuntimeID[runtimeID] = len(tasks) - 1
+			continue
+		}
+
+		if isBackgroundFollowup(name) {
+			runtimeID := firstString(tool.input, "task_id", "session_id", "shell_id", "cell_id")
+			taskIndex, found := byRuntimeID[runtimeID]
+			if !found {
+				continue
+			}
+			task := &tasks[taskIndex]
+			task.Status = backgroundStatus(tool, false)
+			task.UpdatedAt = tool.updatedAt
+			task.Output = appendBackgroundOutput(task.Output, tool.result)
+			if path := extractOutputPath(tool.result); path != "" {
+				task.OutputPath = path
+			}
+		}
+	}
+	sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].StartedAt.After(tasks[j].StartedAt) })
+	return tasks
+}
+
+func normalizeToolName(name string) string {
+	name = strings.ToLower(name)
+	if index := strings.LastIndexAny(name, ".:"); index >= 0 {
+		name = name[index+1:]
+	}
+	return name
+}
+
+func isBackgroundStart(name string, tool *discoveredTool) bool {
+	if name == "bash" {
+		value, _ := tool.input["run_in_background"].(bool)
+		return value
+	}
+	if name != "exec_command" && name != "exec" {
+		return false
+	}
+	result := strings.ToLower(tool.result)
+	command := firstString(tool.input, "cmd", "command")
+	return strings.Contains(result, "running with session id") ||
+		strings.Contains(result, "running with cell id") ||
+		strings.Contains(command, "run_in_background") ||
+		strings.Contains(command, " >") && strings.HasSuffix(strings.TrimSpace(command), "&")
+}
+
+func isBackgroundFollowup(name string) bool {
+	return name == "taskoutput" || name == "task_output" || name == "write_stdin" || name == "wait"
+}
+
+func backgroundTaskName(tool *discoveredTool) string {
+	if description := firstString(tool.input, "description", "name"); description != "" {
+		return description
+	}
+	command := strings.TrimSpace(firstString(tool.input, "cmd", "command"))
+	if line, _, found := strings.Cut(command, "\n"); found {
+		command = line
+	}
+	if len([]rune(command)) > 80 {
+		command = string([]rune(command)[:79]) + "…"
+	}
+	if command != "" {
+		return command
+	}
+	return tool.name
+}
+
+func backgroundStatus(tool *discoveredTool, started bool) string {
+	if tool.isError || tool.status == "failed" {
+		return "failed"
+	}
+	result := strings.ToLower(tool.result)
+	if strings.Contains(result, "running") || strings.Contains(result, "still running") {
+		return "running"
+	}
+	if strings.Contains(result, "exit code") ||
+		strings.Contains(result, "exited with code") ||
+		strings.Contains(result, "completed") ||
+		strings.Contains(result, "finished") ||
+		strings.Contains(result, `"retrieval_status":"success"`) ||
+		strings.Contains(result, "success") {
+		return "completed"
+	}
+	if started {
+		return "running"
+	}
+	return "unknown"
+}
+
+func appendBackgroundOutput(current, next string) string {
+	next = strings.TrimSpace(next)
+	if next == "" || strings.Contains(current, next) {
+		return current
+	}
+	if current == "" {
+		return next
+	}
+	return current + "\n\n" + next
+}
+
+func firstString(input map[string]any, keys ...string) string {
+	for _, key := range keys {
+		switch value := input[key].(type) {
+		case string:
+			if value != "" {
+				return value
+			}
+		case float64:
+			return strconv.FormatInt(int64(value), 10)
+		}
+	}
+	return ""
+}
+
+func extractBackgroundID(text string) string {
+	match := backgroundIDPattern.FindStringSubmatch(text)
+	if len(match) == 2 {
+		return match[1]
+	}
+	return ""
+}
+
+func extractOutputPath(text string) string {
+	match := outputPathPattern.FindStringSubmatch(text)
+	if len(match) == 2 {
+		return strings.TrimRight(match[1], ".,;:)")
+	}
+	return ""
+}
+
+func extractRedirectPath(command string) string {
+	match := redirectPathPattern.FindStringSubmatch(command)
+	if len(match) == 2 {
+		return strings.TrimRight(match[1], ".,;:)")
+	}
+	return ""
+}
diff --git a/lib/httpapi/background_tasks_test.go b/lib/httpapi/background_tasks_test.go
new file mode 100644
index 00000000..493390d4
--- /dev/null
+++ b/lib/httpapi/background_tasks_test.go
@@ -0,0 +1,109 @@
+package httpapi
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/coder/agentapi/lib/jsonlwatcher"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDiscoverBackgroundTasks(t *testing.T) {
+	t.Parallel()
+	now := time.Now()
+	isError := false
+	tests := []struct {
+		name      string
+		agentType string
+		toolName  string
+		input     string
+		result    string
+		wantID    string
+		wantPath  string
+	}{
+		{
+			name: "claude bash", agentType: "claude", toolName: "Bash",
+			input:    `{"command":"make test","description":"Run tests","run_in_background":true}`,
+			result:   "Background task ID: task-42\nOutput: /tmp/claude/tasks/task-42.output",
+			wantID:   "task-42",
+			wantPath: "/tmp/claude/tasks/task-42.output",
+		},
+		{
+			name: "codex exec", agentType: "codex", toolName: "exec_command",
+			input:    `{"cmd":"go test ./..."}`,
+			result:   "Process running with session ID 9912",
+			wantID:   "9912",
+			wantPath: "",
+		},
+	}
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+			messages := []jsonlwatcher.RichMessage{
+				{
+					MessageID: "assistant", Role: "assistant", Timestamp: now,
+					Content: []jsonlwatcher.RichContentBlock{{
+						Type: "tool_use", ToolUseID: "call-1", ToolName: test.toolName,
+						ToolInput: json.RawMessage(test.input), Status: "running",
+					}},
+				},
+				{
+					MessageID: "result", Role: "user", Timestamp: now.Add(time.Second),
+					Content: []jsonlwatcher.RichContentBlock{{
+						Type: "tool_result", ToolUseID: "call-1", Text: test.result,
+						Status: "completed", IsError: &isError,
+					}},
+				},
+			}
+			tasks := discoverBackgroundTasks(messages, test.agentType)
+			require.Len(t, tasks, 1)
+			require.Equal(t, test.wantID, tasks[0].ID)
+			require.Equal(t, test.wantPath, tasks[0].OutputPath)
+			require.Equal(t, "running", tasks[0].Status)
+		})
+	}
+}
+
+func TestDiscoverBackgroundTaskFollowup(t *testing.T) {
+	t.Parallel()
+	now := time.Now()
+	isError := false
+	messages := []jsonlwatcher.RichMessage{
+		{
+			MessageID: "assistant-start", Role: "assistant", Timestamp: now,
+			Content: []jsonlwatcher.RichContentBlock{{
+				Type: "tool_use", ToolUseID: "call-start", ToolName: "exec_command",
+				ToolInput: json.RawMessage(`{"cmd":"go test ./..."}`),
+			}},
+		},
+		{
+			MessageID: "result-start", Role: "user", Timestamp: now.Add(time.Second),
+			Content: []jsonlwatcher.RichContentBlock{{
+				Type: "tool_result", ToolUseID: "call-start",
+				Text: "Process running with session ID 9912", IsError: &isError,
+			}},
+		},
+		{
+			MessageID: "assistant-followup", Role: "assistant", Timestamp: now.Add(2 * time.Second),
+			Content: []jsonlwatcher.RichContentBlock{{
+				Type: "tool_use", ToolUseID: "call-followup", ToolName: "write_stdin",
+				ToolInput: json.RawMessage(`{"session_id":9912}`),
+			}},
+		},
+		{
+			MessageID: "result-followup", Role: "user", Timestamp: now.Add(3 * time.Second),
+			Content: []jsonlwatcher.RichContentBlock{{
+				Type: "tool_result", ToolUseID: "call-followup",
+				Text: "ok github.com/coder/agentapi\nProcess exited with code 0", IsError: &isError,
+			}},
+		},
+	}
+
+	tasks := discoverBackgroundTasks(messages, "codex")
+	require.Len(t, tasks, 1)
+	require.Equal(t, "completed", tasks[0].Status)
+	require.Contains(t, tasks[0].Output, "ok github.com/coder/agentapi")
+	require.Equal(t, now.Add(3*time.Second), tasks[0].UpdatedAt)
+}
diff --git a/lib/httpapi/models.go b/lib/httpapi/models.go
index 9ea4ba95..706f64ab 100644
--- a/lib/httpapi/models.go
+++ b/lib/httpapi/models.go
@@ -59,6 +59,51 @@ type StatusResponse struct {
 	}
 }
 
+// TitleResponse describes the current session title and the state used to
+// derive it. Connection-only states such as browser offline/reconnecting are
+// intentionally not represented because they are client-local.
+type TitleResponse struct {
+	Body struct {
+		Title     string       `json:"title" doc:"Current human-readable session title derived from the latest user task and agent status."`
+		Task      string       `json:"task" doc:"Latest user task used to derive the title. Empty before the first task."`
+		Status    AgentStatus  `json:"status" doc:"Current agent status."`
+		AgentType mf.AgentType `json:"agent_type" doc:"Type of the agent being used by the server."`
+	}
+}
+
+type BackgroundTask struct {
+	ID         string    `json:"id" doc:"Agent-provided background task or session identifier."`
+	Name       string    `json:"name" doc:"Human-readable task name derived from the tool call."`
+	Status     string    `json:"status" doc:"Best-known task status: running, completed, failed, or unknown."`
+	AgentType  string    `json:"agent_type" doc:"Agent family whose tool metadata produced the task."`
+	ToolUseID  string    `json:"tool_use_id" doc:"Structured tool call identifier."`
+	OutputPath string    `json:"output_path,omitempty" doc:"Discovered output file path when the agent reported one."`
+	StartedAt  time.Time `json:"started_at" doc:"Timestamp of the background tool call."`
+	UpdatedAt  time.Time `json:"updated_at" doc:"Timestamp of the latest related tool result."`
+	Output     string    `json:"-"`
+}
+
+type BackgroundTasksResponse struct {
+	Body struct {
+		Tasks []BackgroundTask `json:"tasks" nullable:"false" doc:"Background tasks discovered from structured agent tool metadata."`
+	}
+}
+
+type BackgroundTaskOutputRequest struct {
+	ID   string `path:"id" doc:"Background task identifier."`
+	Tail int    `query:"tail" minimum:"1" maximum:"1048576" default:"131072" doc:"Maximum number of trailing bytes to return."`
+}
+
+type BackgroundTaskOutputResponse struct {
+	Body struct {
+		TaskID    string `json:"task_id"`
+		Path      string `json:"path"`
+		Content   string `json:"content"`
+		Size      int64  `json:"size"`
+		Truncated bool   `json:"truncated"`
+	}
+}
+
 // MessagesResponse represents the list of messages
 type MessagesResponse struct {
 	Body struct {
diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go
index c3d54dcc..8efce12e 100644
--- a/lib/httpapi/server.go
+++ b/lib/httpapi/server.go
@@ -63,6 +63,7 @@ type Server struct {
 	emitter      *EventEmitter
 	chatBasePath string
 	tempDir      string
+	cwd          string
 	clock        quartz.Clock
 	shutdownCtx  context.Context
 	shutdown     context.CancelFunc
@@ -330,6 +331,7 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) {
 		emitter:      emitter,
 		chatBasePath: strings.TrimSuffix(config.ChatBasePath, "/"),
 		tempDir:      tempDir,
+		cwd:          config.CWD,
 		clock:        config.Clock,
 		shutdownCtx:  shutdownCtx,
 		shutdown:     shutdownCancel,
diff --git a/openapi.json b/openapi.json
index eab7069e..cf70fde2 100644
--- a/openapi.json
+++ b/openapi.json
@@ -10,6 +10,114 @@
         "title": "AgentStatus",
         "type": "string"
       },
+      "BackgroundTask": {
+        "additionalProperties": false,
+        "properties": {
+          "agent_type": {
+            "description": "Agent family whose tool metadata produced the task.",
+            "type": "string"
+          },
+          "id": {
+            "description": "Agent-provided background task or session identifier.",
+            "type": "string"
+          },
+          "name": {
+            "description": "Human-readable task name derived from the tool call.",
+            "type": "string"
+          },
+          "output_path": {
+            "description": "Discovered output file path when the agent reported one.",
+            "type": "string"
+          },
+          "started_at": {
+            "description": "Timestamp of the background tool call.",
+            "format": "date-time",
+            "type": "string"
+          },
+          "status": {
+            "description": "Best-known task status: running, completed, failed, or unknown.",
+            "type": "string"
+          },
+          "tool_use_id": {
+            "description": "Structured tool call identifier.",
+            "type": "string"
+          },
+          "updated_at": {
+            "description": "Timestamp of the latest related tool result.",
+            "format": "date-time",
+            "type": "string"
+          }
+        },
+        "required": [
+          "agent_type",
+          "id",
+          "name",
+          "started_at",
+          "status",
+          "tool_use_id",
+          "updated_at"
+        ],
+        "type": "object"
+      },
+      "BackgroundTaskOutputResponseBody": {
+        "additionalProperties": false,
+        "properties": {
+          "$schema": {
+            "description": "A URL to the JSON Schema for this object.",
+            "example": "https://example.com/schemas/BackgroundTaskOutputResponseBody.json",
+            "format": "uri",
+            "readOnly": true,
+            "type": "string"
+          },
+          "content": {
+            "type": "string"
+          },
+          "path": {
+            "type": "string"
+          },
+          "size": {
+            "format": "int64",
+            "type": "integer"
+          },
+          "task_id": {
+            "type": "string"
+          },
+          "truncated": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "content",
+          "path",
+          "size",
+          "task_id",
+          "truncated"
+        ],
+        "type": "object"
+      },
+      "BackgroundTasksResponseBody": {
+        "additionalProperties": false,
+        "properties": {
+          "$schema": {
+            "description": "A URL to the JSON Schema for this object.",
+            "example": "https://example.com/schemas/BackgroundTasksResponseBody.json",
+            "format": "uri",
+            "readOnly": true,
+            "type": "string"
+          },
+          "tasks": {
+            "description": "Background tasks discovered from structured agent tool metadata.",
+            "items": {
+              "$ref": "#/components/schemas/BackgroundTask"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "tasks"
+        ],
+        "type": "object"
+      },
       "ConversationRole": {
         "enum": [
           "agent",
@@ -564,6 +672,41 @@
         ],
         "type": "object"
       },
+      "TitleResponseBody": {
+        "additionalProperties": false,
+        "properties": {
+          "$schema": {
+            "description": "A URL to the JSON Schema for this object.",
+            "example": "https://example.com/schemas/TitleResponseBody.json",
+            "format": "uri",
+            "readOnly": true,
+            "type": "string"
+          },
+          "agent_type": {
+            "description": "Type of the agent being used by the server.",
+            "type": "string"
+          },
+          "status": {
+            "$ref": "#/components/schemas/AgentStatus",
+            "description": "Current agent status."
+          },
+          "task": {
+            "description": "Latest user task used to derive the title. Empty before the first task.",
+            "type": "string"
+          },
+          "title": {
+            "description": "Current human-readable session title derived from the latest user task and agent status.",
+            "type": "string"
+          }
+        },
+        "required": [
+          "agent_type",
+          "status",
+          "task",
+          "title"
+        ],
+        "type": "object"
+      },
       "Transport": {
         "enum": [
           "acp",
@@ -656,6 +799,90 @@
   },
   "openapi": "3.0.3",
   "paths": {
+    "/background-tasks": {
+      "get": {
+        "description": "Lists Claude Code and Codex background tasks discovered from structured tool calls and results.",
+        "operationId": "get-background-tasks",
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BackgroundTasksResponseBody"
+                }
+              }
+            },
+            "description": "OK"
+          },
+          "default": {
+            "content": {
+              "application/problem+json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ErrorModel"
+                }
+              }
+            },
+            "description": "Error"
+          }
+        },
+        "summary": "Get background tasks"
+      }
+    },
+    "/background-tasks/{id}/output": {
+      "get": {
+        "description": "Returns the tail of a discovered background task output file. Arbitrary filesystem paths are not accepted.",
+        "operationId": "get-background-tasks-by-id-output",
+        "parameters": [
+          {
+            "description": "Background task identifier.",
+            "in": "path",
+            "name": "id",
+            "required": true,
+            "schema": {
+              "description": "Background task identifier.",
+              "type": "string"
+            }
+          },
+          {
+            "description": "Maximum number of trailing bytes to return.",
+            "explode": false,
+            "in": "query",
+            "name": "tail",
+            "schema": {
+              "default": 131072,
+              "description": "Maximum number of trailing bytes to return.",
+              "format": "int64",
+              "maximum": 1048576,
+              "minimum": 1,
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BackgroundTaskOutputResponseBody"
+                }
+              }
+            },
+            "description": "OK"
+          },
+          "default": {
+            "content": {
+              "application/problem+json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ErrorModel"
+                }
+              }
+            },
+            "description": "Error"
+          }
+        },
+        "summary": "Get background tasks by ID output"
+      }
+    },
     "/events": {
       "get": {
         "description": "The events are sent as Server-Sent Events (SSE). Initially, the endpoint returns a list of events needed to reconstruct the current state of the conversation and the agent's status. After that, it only returns events that have occurred since the last event was sent.\n\nNote: When an agent is running, the last message in the conversation history is updated frequently, and the endpoint sends a new message update event each time.",
@@ -1109,6 +1336,35 @@
         "summary": "Get status"
       }
     },
+    "/title": {
+      "get": {
+        "description": "Returns the current human-readable session title and the server state used to derive it.",
+        "operationId": "get-title",
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/TitleResponseBody"
+                }
+              }
+            },
+            "description": "OK"
+          },
+          "default": {
+            "content": {
+              "application/problem+json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ErrorModel"
+                }
+              }
+            },
+            "description": "Error"
+          }
+        },
+        "summary": "Get title"
+      }
+    },
     "/upload": {
       "post": {
         "description": "Upload files to the specified upload path.",

From cff7fe2ec849e4fe3c4504dabfe2e0c784f14135 Mon Sep 17 00:00:00 2001
From: "S.Feng" 
Date: Sun, 26 Jul 2026 13:57:37 +0000
Subject: [PATCH 040/122] feat(chat): show background task output details

---
 chat/src/components/explorer.tsx | 90 ++++++++++++++++++++++----------
 lib/httpapi/server.go            | 44 ++++++++++++++++
 openapi.json                     |  2 +-
 3 files changed, 106 insertions(+), 30 deletions(-)

diff --git a/chat/src/components/explorer.tsx b/chat/src/components/explorer.tsx
index 538343e6..9ee6e38a 100644
--- a/chat/src/components/explorer.tsx
+++ b/chat/src/components/explorer.tsx
@@ -2,6 +2,7 @@
 
 import {useMemo, useState} from "react";
 import {
+  ChevronDown,
   ExternalLink,
   FileText,
   FolderSearch,
@@ -44,6 +45,7 @@ export function Explorer({onNavigateTask}: ExplorerProps) {
   } = useChat();
   const [open, setOpen] = useState(false);
   const [activeTab, setActiveTab] = useState("background");
+  const [expandedTask, setExpandedTask] = useState(null);
   const [loadingTask, setLoadingTask] = useState(null);
   const [outputs, setOutputs] = useState>({});
   const tasks = useMemo(
@@ -86,6 +88,11 @@ export function Explorer({onNavigateTask}: ExplorerProps) {
       setLoadingTask(null);
     }
   };
+  const toggleTask = (id: string) => {
+    const opening = expandedTask !== id;
+    setExpandedTask(opening ? id : null);
+    if (opening) void loadOutput(id);
+  };
 
   return (
     
@@ -132,40 +139,60 @@ export function Explorer({onNavigateTask}: ExplorerProps) {
             
{backgroundTasks.map((task) => ( -
-
-
+
+
- {task.output_path && ( - <> -

- {task.output_path} -

- - - )} - {outputs[task.id] !== undefined && ( -
-                      {outputs[task.id] || "(empty output)"}
-                    
+
+ + +
+ + {expandedTask === task.id && ( +
+
+
Tool use ID
+
{task.tool_use_id}
+
Started
+
{formatTaskTime(task.started_at)}
+
Updated
+
{formatTaskTime(task.updated_at)}
+ {task.output_path && ( + <> +
Output path
+
{task.output_path}
+ + )} +
+
+

+ Output +

+ {loadingTask === task.id ? ( +
+ + Loading output… +
+ ) : ( +
+                            {outputs[task.id] || "(no output captured yet)"}
+                          
+ )} +
+
)}
))} @@ -256,6 +283,11 @@ function Empty({text}: {text: string}) { return

{text}

; } +function formatTaskTime(value: string) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + function discoverLinks(messages: Array<{role: string; content: string}>) { const found = new Map(); let task = 0; diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index 8efce12e..cf99625d 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -457,6 +457,9 @@ func (s *Server) registerRoutes() { huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) { o.Description = "Returns the current status of the agent." }) + huma.Get(s.api, "/title", s.getTitle, func(o *huma.Operation) { + o.Description = "Returns the current human-readable session title and the server state used to derive it." + }) // GET /messages endpoint huma.Get(s.api, "/messages", s.getMessages, func(o *huma.Operation) { @@ -471,6 +474,13 @@ func (s *Server) registerRoutes() { "support (currently 'claude' and 'codex') running via PTY transport." }) + huma.Get(s.api, "/background-tasks", s.getBackgroundTasks, func(o *huma.Operation) { + o.Description = "Lists Claude Code and Codex background tasks discovered from structured tool calls and results." + }) + huma.Get(s.api, "/background-tasks/{id}/output", s.getBackgroundTaskOutput, func(o *huma.Operation) { + o.Description = "Returns captured tool output or the tail of a discovered background task output file." + }) + huma.Get(s.api, "/session/export", s.exportSession, func(o *huma.Operation) { o.Description = "Downloads all normalized events from the current agent session, including text, tool calls, tool results, and system lifecycle events." }) @@ -544,6 +554,40 @@ func (s *Server) getStatus(ctx context.Context, input *struct{}) (*StatusRespons return resp, nil } +func (s *Server) getTitle(ctx context.Context, input *struct{}) (*TitleResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + status := convertStatus(s.conversation.Status()) + task := "" + messages := s.conversation.Messages() + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == st.ConversationRoleUser { + task = strings.Join(strings.Fields(messages[i].Message), " ") + break + } + } + runes := []rune(task) + if len(runes) > 60 { + task = strings.TrimSpace(string(runes[:59])) + "…" + } + state := "Ready" + if status == AgentStatusRunning { + state = "Running" + } + title := state + " · AgentAPI" + if task != "" { + title = state + " · " + task + " — AgentAPI" + } + + resp := &TitleResponse{} + resp.Body.Title = title + resp.Body.Task = task + resp.Body.Status = status + resp.Body.AgentType = s.agentType + return resp, nil +} + // getMessages handles GET /messages func (s *Server) getMessages(ctx context.Context, input *struct{}) (*MessagesResponse, error) { s.mu.RLock() diff --git a/openapi.json b/openapi.json index cf70fde2..2ed13f33 100644 --- a/openapi.json +++ b/openapi.json @@ -830,7 +830,7 @@ }, "/background-tasks/{id}/output": { "get": { - "description": "Returns the tail of a discovered background task output file. Arbitrary filesystem paths are not accepted.", + "description": "Returns captured tool output or the tail of a discovered background task output file.", "operationId": "get-background-tasks-by-id-output", "parameters": [ { From ca214a9e0450a01b2de8d793a2bebdcbaa67b78d Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 13:59:35 +0000 Subject: [PATCH 041/122] fix(httpapi): finalize background task statuses --- lib/httpapi/background_tasks.go | 31 +++++++++++----- lib/httpapi/background_tasks_test.go | 55 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/lib/httpapi/background_tasks.go b/lib/httpapi/background_tasks.go index 6d0b1f4d..ecd712a2 100644 --- a/lib/httpapi/background_tasks.go +++ b/lib/httpapi/background_tasks.go @@ -18,9 +18,11 @@ import ( ) var ( - backgroundIDPattern = regexp.MustCompile(`(?i)(?:task|shell|session|cell)(?:\s+id)?["':=\s]+([a-z0-9][a-z0-9_-]*)`) - outputPathPattern = regexp.MustCompile(`(?:^|[\s"'=:])(/[^\s"']+\.(?:out|output))(?:\s|$)`) - redirectPathPattern = regexp.MustCompile(`(?:^|[\s])>{1,2}\s*([^\s"';&]+\.(?:out|output))(?:\s|$)`) + backgroundIDPattern = regexp.MustCompile(`(?i)(?:task|shell|session|cell)(?:\s+id)?["':=\s]+([a-z0-9][a-z0-9_-]*)`) + outputPathPattern = regexp.MustCompile(`(?:^|[\s"'=:])(/[^\s"']+\.(?:out|output))(?:\s|$)`) + redirectPathPattern = regexp.MustCompile(`(?:^|[\s])>{1,2}\s*([^\s"';&]+\.(?:out|output))(?:\s|$)`) + failedExitPattern = regexp.MustCompile(`(?i)(?:exit code|exited with code)\s*[1-9][0-9]*\b`) + successfulExitPattern = regexp.MustCompile(`(?i)(?:exit code|exited with code)\s*0\b`) ) type discoveredTool struct { @@ -263,19 +265,30 @@ func backgroundTaskName(tool *discoveredTool) string { } func backgroundStatus(tool *discoveredTool, started bool) string { - if tool.isError || tool.status == "failed" { + result := strings.ToLower(tool.result) + if tool.isError || + tool.status == "failed" || + tool.status == "cancelled" || + failedExitPattern.MatchString(result) || + strings.Contains(result, `"retrieval_status":"failed"`) || + strings.Contains(result, "failed") || + strings.Contains(result, "failed") { return "failed" } - result := strings.ToLower(tool.result) - if strings.Contains(result, "running") || strings.Contains(result, "still running") { + if strings.Contains(result, "still running") || + strings.Contains(result, "process running") || + strings.Contains(result, "script running") || + strings.Contains(result, `"status":"running"`) || + strings.Contains(result, "running") { return "running" } - if strings.Contains(result, "exit code") || - strings.Contains(result, "exited with code") || + if successfulExitPattern.MatchString(result) || strings.Contains(result, "completed") || strings.Contains(result, "finished") || strings.Contains(result, `"retrieval_status":"success"`) || - strings.Contains(result, "success") { + strings.Contains(result, "success") || + strings.Contains(result, "completed") || + (!started && tool.status == "completed") { return "completed" } if started { diff --git a/lib/httpapi/background_tasks_test.go b/lib/httpapi/background_tasks_test.go index 493390d4..77f71c45 100644 --- a/lib/httpapi/background_tasks_test.go +++ b/lib/httpapi/background_tasks_test.go @@ -107,3 +107,58 @@ func TestDiscoverBackgroundTaskFollowup(t *testing.T) { require.Contains(t, tasks[0].Output, "ok github.com/coder/agentapi") require.Equal(t, now.Add(3*time.Second), tasks[0].UpdatedAt) } + +func TestBackgroundStatus(t *testing.T) { + t.Parallel() + tests := []struct { + name string + tool discoveredTool + started bool + want string + }{ + { + name: "codex still running", + tool: discoveredTool{result: "Script running with cell ID 42", status: "completed"}, + want: "running", + }, + { + name: "codex successful exit", + tool: discoveredTool{result: "Process exited with code 0", status: "completed"}, + want: "completed", + }, + { + name: "codex failed exit", + tool: discoveredTool{result: "Process exited with code 2", status: "completed"}, + want: "failed", + }, + { + name: "claude completed task", + tool: discoveredTool{result: "completed"}, + want: "completed", + }, + { + name: "claude failed retrieval", + tool: discoveredTool{result: `{"retrieval_status":"failed"}`}, + want: "failed", + }, + { + name: "completed followup fallback", + tool: discoveredTool{result: "final output", status: "completed"}, + want: "completed", + }, + { + name: "background start remains running", + tool: discoveredTool{result: "Background task ID: task-1", status: "completed"}, + started: true, + want: "running", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, backgroundStatus(&test.tool, test.started)) + }) + } +} From 06a8deb533b8e8dfae22c5ebeb72888dea6ef4a5 Mon Sep 17 00:00:00 2001 From: "S.Feng" Date: Sun, 26 Jul 2026 14:01:39 +0000 Subject: [PATCH 042/122] fix(chat): align explorer with search toolbar --- chat/src/components/chat.tsx | 24 ++++++++++++------------ chat/src/components/message-list.tsx | 3 +++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/chat/src/components/chat.tsx b/chat/src/components/chat.tsx index 139dcfea..974c0de6 100644 --- a/chat/src/components/chat.tsx +++ b/chat/src/components/chat.tsx @@ -75,18 +75,6 @@ export function Chat() {
)} -
- { - window.requestAnimationFrame(() => - document.getElementById(`task-${number}`)?.scrollIntoView({ - behavior: "smooth", - block: "start", - }), - ); - }} - /> -
void sendMessage("\x1b", "raw")} + headerAction={ + { + window.requestAnimationFrame(() => + document.getElementById(`task-${number}`)?.scrollIntoView({ + behavior: "smooth", + block: "start", + }), + ); + }} + /> + } /> void; onDismissMessage: (clientId: string) => void; onStopTask: () => void; + headerAction?: React.ReactNode; } interface ToolCall { @@ -124,6 +125,7 @@ export default function MessageList({ onEditMessage, onDismissMessage, onStopTask, + headerAction, }: MessageListProps) { const [scrollArea, setScrollArea] = useState(null); const [showScrollButton, setShowScrollButton] = useState(false); @@ -461,6 +463,7 @@ export default function MessageList({ Conversation MD Download conversation Markdown + {headerAction} {taskQuery && filteredTasks.length > 0 && (
+ + {mcpLoading ? ( +
+ + Loading MCP servers… +
+ ) : !mcpSupported ? ( + + ) : ( +
+
+

MCP servers

+

+ Enter the complete server map as JSON. Saving replaces the + existing MCP server set. +

+
+