diff --git a/container/docs/docs.go b/container/docs/docs.go index 1d80019b..ca737924 100644 --- a/container/docs/docs.go +++ b/container/docs/docs.go @@ -15,19 +15,22 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/v1/auth/github/reset": { - "post": { - "description": "Clears any active authentication process", + "/v1/agents": { + "get": { + "description": "Returns a list of all registered coding agents", + "produces": [ + "application/json" + ], "tags": [ - "auth" + "agents" ], - "summary": "Reset authentication state", + "summary": "Get available agents", "responses": { "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { + "type": "array", + "items": { "type": "string" } } @@ -35,43 +38,9 @@ const docTemplate = `{ } } }, - "/v1/auth/github/start": { - "post": { - "description": "Initiates GitHub device flow authentication", - "tags": [ - "auth" - ], - "summary": "Start GitHub authentication", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/internal_handlers.AuthStartResponse" - } - } - } - } - }, - "/v1/auth/github/status": { - "get": { - "description": "Returns the current status of the authentication flow", - "tags": [ - "auth" - ], - "summary": "Get authentication status", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/internal_handlers.AuthStatusResponse" - } - } - } - } - }, - "/v1/claude/hooks": { + "/v1/agents/events": { "post": { - "description": "Receives hook notifications from Claude Code for activity tracking", + "description": "Receives event notifications from coding agents for activity tracking", "consumes": [ "application/json" ], @@ -79,17 +48,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "claude" + "agents" ], - "summary": "Handle Claude hook events", + "summary": "Handle agent events", "parameters": [ { - "description": "Claude hook event", + "description": "Agent event", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeHookEvent" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentEvent" } } ], @@ -106,14 +75,14 @@ const docTemplate = `{ } } }, - "/v1/claude/latest-message": { + "/v1/agents/latest-message": { "get": { - "description": "Returns the most recent assistant message from Claude Code session for a specific worktree", + "description": "Returns the most recent assistant message from any coding agent session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get worktree latest assistant message", "parameters": [ @@ -130,17 +99,15 @@ const docTemplate = `{ "description": "OK", "schema": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": true } } } } }, - "/v1/claude/messages": { + "/v1/agents/messages": { "post": { - "description": "Creates a completion using the claude CLI tool as a subprocess, supporting both streaming and non-streaming responses, with resume functionality", + "description": "Creates a completion using any registered coding agent, supporting both streaming and non-streaming responses, with resume functionality", "consumes": [ "application/json" ], @@ -148,9 +115,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "claude" + "agents" ], - "summary": "Create Claude messages using CLI", + "summary": "Create agent messages", "parameters": [ { "description": "Create completion request", @@ -158,7 +125,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionRequest" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentCompletionRequest" } } ], @@ -166,7 +133,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentCompletionResponse" } }, "400": { @@ -190,14 +157,14 @@ const docTemplate = `{ } } }, - "/v1/claude/session": { + "/v1/agents/session": { "get": { - "description": "Returns Claude Code session metadata for a specific worktree", + "description": "Returns coding agent session metadata for a specific worktree", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get worktree session summary", "parameters": [ @@ -207,26 +174,32 @@ const docTemplate = `{ "name": "worktree_path", "in": "query", "required": true + }, + { + "type": "string", + "description": "Agent type (defaults to Claude for backward compatibility)", + "name": "agent_type", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary" } } } } }, - "/v1/claude/session/{uuid}": { + "/v1/agents/session/{uuid}": { "get": { "description": "Returns complete session data including all messages for a specific session UUID", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get session by UUID", "parameters": [ @@ -242,20 +215,20 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentFullSessionData" } } } } }, - "/v1/claude/sessions": { + "/v1/agents/sessions": { "get": { - "description": "Returns Claude Code session metadata for all worktrees with Claude data", + "description": "Returns coding agent session metadata for all worktrees with agent data", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get all worktree session summaries", "responses": { @@ -264,34 +237,42 @@ const docTemplate = `{ "schema": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary" } } } } } }, - "/v1/claude/settings": { + "/v1/agents/settings": { "get": { - "description": "Returns Claude Code configuration settings including theme, authentication status, and other metadata", + "description": "Returns coding agent configuration settings including authentication status and other metadata", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" + ], + "summary": "Get agent settings", + "parameters": [ + { + "type": "string", + "description": "Agent type (defaults to Claude)", + "name": "agent_type", + "in": "query" + } ], - "summary": "Get Claude settings", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettings" } } } }, "put": { - "description": "Updates Claude Code configuration settings (theme and notifications)", + "description": "Updates coding agent configuration settings", "consumes": [ "application/json" ], @@ -299,17 +280,23 @@ const docTemplate = `{ "application/json" ], "tags": [ - "claude" + "agents" ], - "summary": "Update Claude settings", + "summary": "Update agent settings", "parameters": [ + { + "type": "string", + "description": "Agent type (defaults to Claude)", + "name": "agent_type", + "in": "query" + }, { "description": "Settings update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettingsUpdateRequest" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettingsUpdateRequest" } } ], @@ -317,20 +304,20 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettings" } } } } }, - "/v1/claude/todos": { + "/v1/agents/todos": { "get": { - "description": "Returns the most recent TodoWrite structure from Claude Code session for a specific worktree", + "description": "Returns the most recent TodoWrite structure from any coding agent session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get worktree todos", "parameters": [ @@ -355,64 +342,63 @@ const docTemplate = `{ } } }, - "/v1/events": { - "get": { - "description": "Streams real-time events in Server-Sent Events format. Events include port changes, git status, processes, and container status updates.\n\n## Event Types\n\n### Port Events\n- **port:opened**: Fired when a new port is detected\n- ` + "`" + `port` + "`" + ` (int): Port number\n- ` + "`" + `service` + "`" + ` (string): Service type (http, tcp)\n- ` + "`" + `protocol` + "`" + ` (string): Protocol used\n- ` + "`" + `title` + "`" + ` (string): Service title/name if detected\n- **port:closed**: Fired when a port is no longer available\n- ` + "`" + `port` + "`" + ` (int): Port number that was closed\n\n### Git Events\n- **git:dirty**: Fired when git workspace has uncommitted changes\n- ` + "`" + `workspace` + "`" + ` (string): Workspace path\n- ` + "`" + `files` + "`" + ` ([]string): List of modified files\n- **git:clean**: Fired when git workspace becomes clean\n- ` + "`" + `workspace` + "`" + ` (string): Workspace path\n\n### Process Events\n- **process:started**: Fired when a new process starts\n- ` + "`" + `pid` + "`" + ` (int): Process ID\n- ` + "`" + `command` + "`" + ` (string): Command that was executed\n- ` + "`" + `workspace` + "`" + ` (string): Workspace where process started\n- **process:stopped**: Fired when a process terminates\n- ` + "`" + `pid` + "`" + ` (int): Process ID that stopped\n- ` + "`" + `exitCode` + "`" + ` (int): Exit code of the process\n\n### Container Events\n- **container:status**: Fired when container status changes\n- ` + "`" + `status` + "`" + ` (string): Container status (running, stopped, error)\n- ` + "`" + `message` + "`" + ` (string): Optional status message\n\n### System Events\n- **heartbeat**: Sent every 5 seconds to keep connection alive\n- ` + "`" + `timestamp` + "`" + ` (int64): Current timestamp in milliseconds\n- ` + "`" + `uptime` + "`" + ` (int64): Server uptime in milliseconds\n\n## Message Format\nEach SSE message is a JSON object with:\n- ` + "`" + `event` + "`" + `: Event object containing ` + "`" + `type` + "`" + ` and ` + "`" + `payload` + "`" + `\n- ` + "`" + `timestamp` + "`" + `: Event timestamp in milliseconds\n- ` + "`" + `id` + "`" + `: Unique event identifier\n\n## Connection Behavior\n- Auto-reconnects on disconnection\n- Sends current state on initial connection\n- Heartbeat every 5 seconds\n- Rate limited to prevent spam", - "consumes": [ - "text/event-stream" - ], - "produces": [ - "text/event-stream" - ], + "/v1/auth/github/reset": { + "post": { + "description": "Clears any active authentication process", "tags": [ - "events" + "auth" ], - "summary": "Server-Sent Events endpoint for real-time container events", + "summary": "Reset authentication state", "responses": { "200": { - "description": "SSE stream of events", + "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.SSEMessage" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/git/branches/{repo_id}": { - "get": { - "description": "Returns a list of remote branches for a specific repository", - "produces": [ - "application/json" - ], + "/v1/auth/github/start": { + "post": { + "description": "Initiates GitHub device flow authentication", "tags": [ - "git" + "auth" ], - "summary": "Get repository branches", - "parameters": [ - { - "type": "string", - "description": "Repository ID", - "name": "repo_id", - "in": "path", - "required": true + "summary": "Start GitHub authentication", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthStartResponse" + } } + } + } + }, + "/v1/auth/github/status": { + "get": { + "description": "Returns the current status of the authentication flow", + "tags": [ + "auth" ], + "summary": "Get authentication status", "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.AuthStatusResponse" } } } } }, - "/v1/git/checkout/{org}/{repo}": { + "/v1/claude/hooks": { "post": { - "description": "Clones a GitHub repository as a bare repo and creates initial worktree", + "description": "Receives hook notifications from Claude Code for activity tracking", "consumes": [ "application/json" ], @@ -420,93 +406,98 @@ const docTemplate = `{ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Checkout a GitHub repository", + "summary": "Handle Claude hook events", "parameters": [ { - "type": "string", - "description": "Organization name", - "name": "org", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Repository name", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Branch name (optional)", - "name": "branch", - "in": "query" + "description": "Claude hook event", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeHookEvent" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.CheckoutResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/git/github/repos": { + "/v1/claude/latest-message": { "get": { - "description": "Returns a list of GitHub repositories accessible to the authenticated user", + "description": "Returns the most recent assistant message from Claude Code session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "git" + "claude" + ], + "summary": "Get worktree latest assistant message", + "parameters": [ + { + "type": "string", + "description": "Worktree path", + "name": "worktree_path", + "in": "query", + "required": true + } ], - "summary": "List GitHub repositories", "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/internal_handlers.GitHubRepository" + "type": "object", + "additionalProperties": { + "type": "string" } } } } } }, - "/v1/git/repositories/{id}": { - "delete": { - "description": "Removes a repository and all its associated worktrees from disk and state management", + "/v1/claude/messages": { + "post": { + "description": "Creates a completion using the claude CLI tool as a subprocess, supporting both streaming and non-streaming responses, with resume functionality", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Delete repository", + "summary": "Create Claude messages using CLI", "parameters": [ { - "type": "string", - "description": "Repository ID", - "name": "id", - "in": "path", - "required": true + "description": "Create completion request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionResponse" } }, "400": { - "description": "Repository not found or deletion failed", + "description": "Bad Request", "schema": { "type": "object", "additionalProperties": { @@ -515,7 +506,7 @@ const docTemplate = `{ } }, "500": { - "description": "Internal server error", + "description": "Internal Server Error", "schema": { "type": "object", "additionalProperties": { @@ -526,88 +517,108 @@ const docTemplate = `{ } } }, - "/v1/git/repositories/{id}/github": { - "post": { - "description": "Creates a new GitHub repository and sets it as the origin for a local repository", - "consumes": [ + "/v1/claude/session": { + "get": { + "description": "Returns Claude Code session metadata for a specific worktree", + "produces": [ "application/json" ], + "tags": [ + "claude" + ], + "summary": "Get worktree session summary", + "parameters": [ + { + "type": "string", + "description": "Worktree path", + "name": "worktree_path", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" + } + } + } + } + }, + "/v1/claude/session/{uuid}": { + "get": { + "description": "Returns complete session data including all messages for a specific session UUID", "produces": [ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Create GitHub repository", + "summary": "Get session by UUID", "parameters": [ { "type": "string", - "description": "Repository ID", - "name": "id", + "description": "Session UUID", + "name": "uuid", "in": "path", "required": true - }, - { - "description": "Repository creation request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryRequest" - } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryResponse" - } - }, - "400": { - "description": "Invalid request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" } - }, - "500": { - "description": "Internal server error", + } + } + } + }, + "/v1/claude/sessions": { + "get": { + "description": "Returns Claude Code session metadata for all worktrees with Claude data", + "produces": [ + "application/json" + ], + "tags": [ + "claude" + ], + "summary": "Get all worktree session summaries", + "responses": { + "200": { + "description": "OK", "schema": { "type": "object", "additionalProperties": { - "type": "string" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" } } } } } }, - "/v1/git/status": { + "/v1/claude/settings": { "get": { - "description": "Returns the current repository and worktree status", + "description": "Returns Claude Code configuration settings including theme, authentication status, and other metadata", "produces": [ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Get Git status", + "summary": "Get Claude settings", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.GitStatus" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" } } } - } - }, - "/v1/git/template": { - "post": { - "description": "Creates a new Git repository and workspace from a predefined project template", + }, + "put": { + "description": "Updates Claude Code configuration settings (theme and notifications)", "consumes": [ "application/json" ], @@ -615,17 +626,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Create workspace from template", + "summary": "Update Claude settings", "parameters": [ { - "description": "Template creation request", + "description": "Settings update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_handlers.CreateTemplateRequest" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettingsUpdateRequest" } } ], @@ -633,92 +644,82 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "400": { - "description": "Invalid request or template not found", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" } } } } }, - "/v1/git/worktrees": { + "/v1/claude/todos": { "get": { - "description": "Returns a list of all worktrees for the current repository with fast cache-enhanced responses", + "description": "Returns the most recent TodoWrite structure from Claude Code session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "git" + "claude" + ], + "summary": "Get worktree todos", + "parameters": [ + { + "type": "string", + "description": "Worktree path", + "name": "worktree_path", + "in": "query", + "required": true + } ], - "summary": "List all worktrees", "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/internal_handlers.EnhancedWorktree" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Todo" } } } } } }, - "/v1/git/worktrees/cleanup": { - "post": { - "description": "Removes worktrees that have been fully merged into their source branch", + "/v1/events": { + "get": { + "description": "Streams real-time events in Server-Sent Events format. Events include port changes, git status, processes, and container status updates.\n\n## Event Types\n\n### Port Events\n- **port:opened**: Fired when a new port is detected\n- ` + "`" + `port` + "`" + ` (int): Port number\n- ` + "`" + `service` + "`" + ` (string): Service type (http, tcp)\n- ` + "`" + `protocol` + "`" + ` (string): Protocol used\n- ` + "`" + `title` + "`" + ` (string): Service title/name if detected\n- **port:closed**: Fired when a port is no longer available\n- ` + "`" + `port` + "`" + ` (int): Port number that was closed\n\n### Git Events\n- **git:dirty**: Fired when git workspace has uncommitted changes\n- ` + "`" + `workspace` + "`" + ` (string): Workspace path\n- ` + "`" + `files` + "`" + ` ([]string): List of modified files\n- **git:clean**: Fired when git workspace becomes clean\n- ` + "`" + `workspace` + "`" + ` (string): Workspace path\n\n### Process Events\n- **process:started**: Fired when a new process starts\n- ` + "`" + `pid` + "`" + ` (int): Process ID\n- ` + "`" + `command` + "`" + ` (string): Command that was executed\n- ` + "`" + `workspace` + "`" + ` (string): Workspace where process started\n- **process:stopped**: Fired when a process terminates\n- ` + "`" + `pid` + "`" + ` (int): Process ID that stopped\n- ` + "`" + `exitCode` + "`" + ` (int): Exit code of the process\n\n### Container Events\n- **container:status**: Fired when container status changes\n- ` + "`" + `status` + "`" + ` (string): Container status (running, stopped, error)\n- ` + "`" + `message` + "`" + ` (string): Optional status message\n\n### System Events\n- **heartbeat**: Sent every 5 seconds to keep connection alive\n- ` + "`" + `timestamp` + "`" + ` (int64): Current timestamp in milliseconds\n- ` + "`" + `uptime` + "`" + ` (int64): Server uptime in milliseconds\n\n## Message Format\nEach SSE message is a JSON object with:\n- ` + "`" + `event` + "`" + `: Event object containing ` + "`" + `type` + "`" + ` and ` + "`" + `payload` + "`" + `\n- ` + "`" + `timestamp` + "`" + `: Event timestamp in milliseconds\n- ` + "`" + `id` + "`" + `: Unique event identifier\n\n## Connection Behavior\n- Auto-reconnects on disconnection\n- Sends current state on initial connection\n- Heartbeat every 5 seconds\n- Rate limited to prevent spam", + "consumes": [ + "text/event-stream" + ], "produces": [ - "application/json" + "text/event-stream" ], "tags": [ - "git" + "events" ], - "summary": "Cleanup merged worktrees", + "summary": "Server-Sent Events endpoint for real-time container events", "responses": { "200": { - "description": "OK", + "description": "SSE stream of events", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/internal_handlers.SSEMessage" } } } } }, - "/v1/git/worktrees/{id}": { - "delete": { - "description": "Removes a worktree from the repository", + "/v1/git/branches/{repo_id}": { + "get": { + "description": "Returns a list of remote branches for a specific repository", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Delete worktree", + "summary": "Get repository branches", "parameters": [ { "type": "string", - "description": "Worktree ID", - "name": "id", + "description": "Repository ID", + "name": "repo_id", "in": "path", "required": true } @@ -727,13 +728,18 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "type": "array", + "items": { + "type": "string" + } } } } - }, - "patch": { - "description": "Updates specific fields of a worktree (for testing purposes)", + } + }, + "/v1/git/checkout/{org}/{repo}": { + "post": { + "description": "Clones a GitHub repository as a bare repo and creates initial worktree", "consumes": [ "application/json" ], @@ -743,92 +749,79 @@ const docTemplate = `{ "tags": [ "git" ], - "summary": "Update worktree fields", + "summary": "Checkout a GitHub repository", "parameters": [ { "type": "string", - "description": "Worktree ID", - "name": "id", + "description": "Organization name", + "name": "org", "in": "path", "required": true }, { - "description": "Fields to update", - "name": "updates", - "in": "body", - "required": true, - "schema": { - "type": "object" - } + "type": "string", + "description": "Repository name", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Branch name (optional)", + "name": "branch", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Worktree" + "$ref": "#/definitions/internal_handlers.CheckoutResponse" } } } } }, - "/v1/git/worktrees/{id}/diff": { + "/v1/git/github/repos": { "get": { - "description": "Returns the diff for a worktree against its source branch, including all staged/unstaged changes", + "description": "Returns a list of GitHub repositories accessible to the authenticated user", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Get worktree diff", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "List GitHub repositories", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeDiffResponse" + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers.GitHubRepository" + } } } } } }, - "/v1/git/worktrees/{id}/graduate": { - "post": { - "description": "Triggers renaming of any branch to a semantic name using Claude or a custom name", - "consumes": [ - "application/json" - ], + "/v1/git/repositories/{id}": { + "delete": { + "description": "Removes a repository and all its associated worktrees from disk and state management", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Rename branch", + "summary": "Delete repository", "parameters": [ { "type": "string", - "description": "Worktree ID", + "description": "Repository ID", "name": "id", "in": "path", "required": true - }, - { - "description": "Graduation request with optional custom branch name", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/internal_handlers.GraduateBranchRequest" - } } ], "responses": { @@ -836,31 +829,11 @@ const docTemplate = `{ "description": "OK", "schema": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": true } }, "400": { - "description": "Bad request (invalid branch name, branch already exists, etc.)", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "404": { - "description": "Worktree not found", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "422": { - "description": "No title available for automatic naming", + "description": "Repository not found or deletion failed", "schema": { "type": "object", "additionalProperties": { @@ -880,9 +853,9 @@ const docTemplate = `{ } } }, - "/v1/git/worktrees/{id}/merge": { + "/v1/git/repositories/{id}/github": { "post": { - "description": "Merges a local repo worktree's changes back to the main repository", + "description": "Creates a new GitHub repository and sets it as the origin for a local repository", "consumes": [ "application/json" ], @@ -892,24 +865,22 @@ const docTemplate = `{ "tags": [ "git" ], - "summary": "Merge worktree to main", + "summary": "Create GitHub repository", "parameters": [ { "type": "string", - "description": "Worktree ID", + "description": "Repository ID", "name": "id", "in": "path", "required": true }, { - "description": "Merge options", - "name": "body", + "description": "Repository creation request", + "name": "request", "in": "body", + "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryRequest" } } ], @@ -917,71 +888,53 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryResponse" } - } - } - } - }, - "/v1/git/worktrees/{id}/merge/check": { - "get": { - "description": "Checks if merging a worktree to main would cause conflicts", - "produces": [ - "application/json" - ], - "tags": [ - "git" - ], - "summary": "Check merge conflicts", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", + }, + "400": { + "description": "Invalid request", "schema": { - "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/git/worktrees/{id}/pr": { + "/v1/git/status": { "get": { - "description": "Gets information about an existing pull request for a worktree branch", + "description": "Returns the current repository and worktree status", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Get pull request info", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "Get Git status", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestInfo" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.GitStatus" } } } - }, - "put": { - "description": "Updates an existing pull request for a worktree branch", + } + }, + "/v1/git/template": { + "post": { + "description": "Creates a new Git repository and workspace from a predefined project template", "consumes": [ "application/json" ], @@ -991,22 +944,15 @@ const docTemplate = `{ "tags": [ "git" ], - "summary": "Update pull request", + "summary": "Create workspace from template", "parameters": [ { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Pull request details", + "description": "Template creation request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" + "$ref": "#/definitions/internal_handlers.CreateTemplateRequest" } } ], @@ -1014,90 +960,87 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid request or template not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } } - }, - "post": { - "description": "Creates a pull request for a worktree branch", - "consumes": [ - "application/json" - ], + } + }, + "/v1/git/worktrees": { + "get": { + "description": "Returns a list of all worktrees for the current repository with fast cache-enhanced responses", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Create pull request", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Pull request details", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" - } - } - ], + "summary": "List all worktrees", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers.EnhancedWorktree" + } } } } } }, - "/v1/git/worktrees/{id}/preview": { + "/v1/git/worktrees/cleanup": { "post": { - "description": "Creates a preview branch in the main repo for viewing changes outside container", + "description": "Removes worktrees that have been fully merged into their source branch", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Create worktree preview", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "Cleanup merged worktrees", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "type": "object", + "additionalProperties": true } } } } }, - "/v1/git/worktrees/{id}/refresh": { - "post": { - "description": "Forces an immediate refresh of a worktree's cached status including commit counts", + "/v1/git/worktrees/{id}": { + "delete": { + "description": "Removes a worktree from the repository", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Force refresh worktree status", + "summary": "Delete worktree", "parameters": [ { "type": "string", @@ -1111,18 +1054,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" } } } - } - }, - "/v1/git/worktrees/{id}/sync": { - "post": { - "description": "Syncs a worktree with its source branch using merge or rebase strategy", + }, + "patch": { + "description": "Updates specific fields of a worktree (for testing purposes)", "consumes": [ "application/json" ], @@ -1132,7 +1070,7 @@ const docTemplate = `{ "tags": [ "git" ], - "summary": "Sync worktree with source branch", + "summary": "Update worktree fields", "parameters": [ { "type": "string", @@ -1142,15 +1080,12 @@ const docTemplate = `{ "required": true }, { - "description": "Sync options", - "name": "body", + "description": "Fields to update", + "name": "updates", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "type": "object" } } ], @@ -1158,22 +1093,22 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Worktree" } } } } }, - "/v1/git/worktrees/{id}/sync/check": { + "/v1/git/worktrees/{id}/diff": { "get": { - "description": "Checks if syncing a worktree would cause merge conflicts", + "description": "Returns the diff for a worktree against its source branch, including all staged/unstaged changes", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Check sync conflicts", + "summary": "Get worktree diff", "parameters": [ { "type": "string", @@ -1187,15 +1122,15 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" + "$ref": "#/definitions/internal_handlers.WorktreeDiffResponse" } } } } }, - "/v1/notifications": { + "/v1/git/worktrees/{id}/graduate": { "post": { - "description": "Sends a notification event to all connected SSE clients, including the TUI app which can display native macOS notifications", + "description": "Triggers renaming of any branch to a semantic name using Claude or a custom name", "consumes": [ "application/json" ], @@ -1203,23 +1138,29 @@ const docTemplate = `{ "application/json" ], "tags": [ - "notifications" + "git" ], - "summary": "Send notification", + "summary": "Rename branch", "parameters": [ { - "description": "Notification details", - "name": "notification", + "type": "string", + "description": "Worktree ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Graduation request with optional custom branch name", + "name": "request", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/internal_handlers.NotificationPayload" + "$ref": "#/definitions/internal_handlers.GraduateBranchRequest" } } ], "responses": { "200": { - "description": "Success response", + "description": "OK", "schema": { "type": "object", "additionalProperties": { @@ -1228,71 +1169,25 @@ const docTemplate = `{ } }, "400": { - "description": "Bad request", + "description": "Bad request (invalid branch name, branch already exists, etc.)", "schema": { "type": "object", "additionalProperties": { "type": "string" } } - } - } - } - }, - "/v1/ports": { - "get": { - "description": "Returns a list of all currently detected ports with their service information", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ports" - ], - "summary": "Get detected ports", - "responses": { - "200": { - "description": "List of detected ports and services", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/v1/ports/mappings": { - "post": { - "description": "Records a mapping from container port to host port and broadcasts an SSE event", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ports" - ], - "summary": "Set host port mapping for a container port", - "parameters": [ - { - "description": "Mapping object with 'port' and 'host_port'", - "name": "mapping", - "in": "body", - "required": true, + }, + "404": { + "description": "Worktree not found", "schema": { "type": "object", "additionalProperties": { - "type": "integer" + "type": "string" } } - } - ], - "responses": { - "200": { - "description": "Mapping set", + }, + "422": { + "description": "No title available for automatic naming", "schema": { "type": "object", "additionalProperties": { @@ -1300,8 +1195,8 @@ const docTemplate = `{ } } }, - "400": { - "description": "Invalid request", + "500": { + "description": "Internal server error", "schema": { "type": "object", "additionalProperties": { @@ -1312,9 +1207,9 @@ const docTemplate = `{ } } }, - "/v1/ports/mappings/{port}": { - "delete": { - "description": "Removes a mapping and broadcasts an SSE event with host_port=0", + "/v1/git/worktrees/{id}/merge": { + "post": { + "description": "Merges a local repo worktree's changes back to the main repository", "consumes": [ "application/json" ], @@ -1322,230 +1217,219 @@ const docTemplate = `{ "application/json" ], "tags": [ - "ports" + "git" ], - "summary": "Delete host port mapping for a container port", + "summary": "Merge worktree to main", "parameters": [ { - "type": "integer", - "description": "Container port", - "name": "port", + "type": "string", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true - } - ], - "responses": { - "200": { - "description": "Mapping deleted", + }, + { + "description": "Merge options", + "name": "body", + "in": "body", "schema": { "type": "object", "additionalProperties": { "type": "string" } } - }, - "400": { - "description": "Invalid port", + } + ], + "responses": { + "200": { + "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" } } } } }, - "/v1/ports/{port}": { + "/v1/git/worktrees/{id}/merge/check": { "get": { - "description": "Returns detailed information about a specific port if it exists", - "consumes": [ - "application/json" - ], + "description": "Checks if merging a worktree to main would cause conflicts", "produces": [ "application/json" ], "tags": [ - "ports" + "git" ], - "summary": "Get port information", + "summary": "Check merge conflicts", "parameters": [ { - "type": "integer", - "description": "Port number", - "name": "port", + "type": "string", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true } ], "responses": { "200": { - "description": "Port information", - "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_services.ServiceInfo" - } - }, - "404": { - "description": "Port not found", + "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" } } } } }, - "/v1/pty": { + "/v1/git/worktrees/{id}/pr": { "get": { - "description": "Establishes a WebSocket connection for terminal access", + "description": "Gets information about an existing pull request for a worktree branch", + "produces": [ + "application/json" + ], "tags": [ - "pty" + "git" ], - "summary": "Create PTY WebSocket connection", + "summary": "Get pull request info", "parameters": [ { "type": "string", - "description": "Session ID", - "name": "session", - "in": "query", + "description": "Worktree ID", + "name": "id", + "in": "path", "required": true } ], "responses": { - "101": { - "description": "Switching Protocols", + "200": { + "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestInfo" } } } - } - }, - "/v1/pty/sse": { - "get": { - "description": "Establishes a Server-Sent Events connection for terminal streaming", + }, + "put": { + "description": "Updates an existing pull request for a worktree branch", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "pty" + "git" ], - "summary": "Create PTY SSE connection", + "summary": "Update pull request", "parameters": [ { "type": "string", - "description": "Session ID", - "name": "session", - "in": "query", + "description": "Worktree ID", + "name": "id", + "in": "path", "required": true }, { - "type": "string", - "description": "Agent type (claude, bash, etc)", - "name": "agent", - "in": "query" - }, - { - "type": "string", - "description": "Prompt to send to PTY session", - "name": "prompt", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Stream of terminal events" + "description": "Pull request details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" + } } - } - } - }, - "/v1/sessions": { - "get": { - "description": "Returns all sessions including ended ones", - "produces": [ - "application/json" ], - "tags": [ - "sessions" - ], - "summary": "Get all sessions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.SessionsResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" } } } - } - }, - "/v1/sessions/active": { - "get": { - "description": "Returns all active sessions (not ended)", + }, + "post": { + "description": "Creates a pull request for a worktree branch", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "sessions" + "git" + ], + "summary": "Create pull request", + "parameters": [ + { + "type": "string", + "description": "Worktree ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Pull request details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" + } + } ], - "summary": "Get active sessions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.SessionsResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" } } } } }, - "/v1/sessions/workspace/{workspace}": { - "get": { - "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", + "/v1/git/worktrees/{id}/preview": { + "post": { + "description": "Creates a preview branch in the main repo for viewing changes outside container", "produces": [ "application/json" ], "tags": [ - "sessions" + "git" ], - "summary": "Get session by workspace", + "summary": "Create worktree preview", "parameters": [ { "type": "string", - "description": "Workspace directory path", - "name": "workspace", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true - }, - { - "type": "boolean", - "description": "Include full session data with messages and user prompts", - "name": "full", - "in": "query" } ], "responses": { "200": { - "description": "Basic session info when full=false", + "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.ActiveSessionInfo" + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" } } } - }, - "delete": { - "description": "Removes a session from the active sessions mapping", + } + }, + "/v1/git/worktrees/{id}/refresh": { + "post": { + "description": "Forces an immediate refresh of a worktree's cached status including commit counts", "produces": [ "application/json" ], "tags": [ - "sessions" + "git" ], - "summary": "Delete session", + "summary": "Force refresh worktree status", "parameters": [ { "type": "string", - "description": "Workspace directory path", - "name": "workspace", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true } @@ -1554,34 +1438,74 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.DeleteSessionResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/sessions/workspace/{workspace}/session/{sessionId}": { - "get": { - "description": "Returns complete session data for a specific session ID within a workspace", + "/v1/git/worktrees/{id}/sync": { + "post": { + "description": "Syncs a worktree with its source branch using merge or rebase strategy", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "sessions" + "git" ], - "summary": "Get session by ID", + "summary": "Sync worktree with source branch", "parameters": [ { "type": "string", - "description": "Workspace directory path", - "name": "workspace", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true }, + { + "description": "Sync options", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + } + } + } + } + }, + "/v1/git/worktrees/{id}/sync/check": { + "get": { + "description": "Checks if syncing a worktree would cause merge conflicts", + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Check sync conflicts", + "parameters": [ { "type": "string", - "description": "Session ID (UUID)", - "name": "sessionId", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true } @@ -1590,58 +1514,835 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" + "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" } } } } }, - "/v1/upload": { + "/v1/notifications": { "post": { - "description": "Upload a file to /tmp/uploads directory with automatic conflict resolution", + "description": "Sends a notification event to all connected SSE clients, including the TUI app which can display native macOS notifications", "consumes": [ - "multipart/form-data" + "application/json" ], "produces": [ "application/json" ], "tags": [ - "upload" + "notifications" ], - "summary": "Upload a file", + "summary": "Send notification", "parameters": [ { - "type": "file", - "description": "File to upload", - "name": "file", - "in": "formData", - "required": true + "description": "Notification details", + "name": "notification", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.NotificationPayload" + } } ], "responses": { "200": { - "description": "OK", + "description": "Success response", "schema": { - "$ref": "#/definitions/internal_handlers.UploadResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "400": { - "description": "Bad Request", + "description": "Bad request", "schema": { - "$ref": "#/definitions/internal_handlers.UploadResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/v1/ports": { + "get": { + "description": "Returns a list of all currently detected ports with their service information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Get detected ports", + "responses": { + "200": { + "description": "List of detected ports and services", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/ports/mappings": { + "post": { + "description": "Records a mapping from container port to host port and broadcasts an SSE event", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Set host port mapping for a container port", + "parameters": [ + { + "description": "Mapping object with 'port' and 'host_port'", + "name": "mapping", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + ], + "responses": { + "200": { + "description": "Mapping set", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "500": { - "description": "Internal Server Error", + "400": { + "description": "Invalid request", "schema": { - "$ref": "#/definitions/internal_handlers.UploadResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } - } - }, - "definitions": { + }, + "/v1/ports/mappings/{port}": { + "delete": { + "description": "Removes a mapping and broadcasts an SSE event with host_port=0", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Delete host port mapping for a container port", + "parameters": [ + { + "type": "integer", + "description": "Container port", + "name": "port", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Mapping deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid port", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/v1/ports/{port}": { + "get": { + "description": "Returns detailed information about a specific port if it exists", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Get port information", + "parameters": [ + { + "type": "integer", + "description": "Port number", + "name": "port", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Port information", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_services.ServiceInfo" + } + }, + "404": { + "description": "Port not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/v1/pty": { + "get": { + "description": "Establishes a WebSocket connection for terminal access", + "tags": [ + "pty" + ], + "summary": "Create PTY WebSocket connection", + "parameters": [ + { + "type": "string", + "description": "Session ID", + "name": "session", + "in": "query", + "required": true + } + ], + "responses": { + "101": { + "description": "Switching Protocols", + "schema": { + "type": "string" + } + } + } + } + }, + "/v1/pty/sse": { + "get": { + "description": "Establishes a Server-Sent Events connection for terminal streaming", + "tags": [ + "pty" + ], + "summary": "Create PTY SSE connection", + "parameters": [ + { + "type": "string", + "description": "Session ID", + "name": "session", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Agent type (claude, bash, etc)", + "name": "agent", + "in": "query" + }, + { + "type": "string", + "description": "Prompt to send to PTY session", + "name": "prompt", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Stream of terminal events" + } + } + } + }, + "/v1/sessions": { + "get": { + "description": "Returns all sessions including ended ones", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get all sessions", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SessionsResponse" + } + } + } + } + }, + "/v1/sessions/active": { + "get": { + "description": "Returns all active sessions (not ended)", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get active sessions", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SessionsResponse" + } + } + } + } + }, + "/v1/sessions/workspace/{workspace}": { + "get": { + "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get session by workspace", + "parameters": [ + { + "type": "string", + "description": "Workspace directory path", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Include full session data with messages and user prompts", + "name": "full", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Basic session info when full=false", + "schema": { + "$ref": "#/definitions/internal_handlers.ActiveSessionInfo" + } + } + } + }, + "delete": { + "description": "Removes a session from the active sessions mapping", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Delete session", + "parameters": [ + { + "type": "string", + "description": "Workspace directory path", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.DeleteSessionResponse" + } + } + } + } + }, + "/v1/sessions/workspace/{workspace}/session/{sessionId}": { + "get": { + "description": "Returns complete session data for a specific session ID within a workspace", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get session by ID", + "parameters": [ + { + "type": "string", + "description": "Workspace directory path", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID (UUID)", + "name": "sessionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" + } + } + } + } + }, + "/v1/upload": { + "post": { + "description": "Upload a file to /tmp/uploads directory with automatic conflict resolution", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "Upload a file", + "parameters": [ + { + "type": "file", + "description": "File to upload", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.UploadResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.UploadResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_handlers.UploadResponse" + } + } + } + } + } + }, + "definitions": { + "github_com_vanpelt_catnip_internal_models.AgentCompletionRequest": { + "type": "object", + "properties": { + "agent_options": { + "description": "Agent-specific options", + "type": "object", + "additionalProperties": true + }, + "max_turns": { + "description": "Maximum number of turns in the conversation", + "type": "integer", + "example": 10 + }, + "model": { + "description": "Optional model override (agent-specific)", + "type": "string", + "example": "claude-3-5-sonnet-20241022" + }, + "prompt": { + "description": "The prompt/message to send to the agent", + "type": "string", + "example": "Help me debug this error" + }, + "resume": { + "description": "Whether to resume the most recent session for this working directory", + "type": "boolean", + "example": true + }, + "stream": { + "description": "Whether to stream the response", + "type": "boolean", + "example": true + }, + "suppress_events": { + "description": "Whether to suppress events for this automated operation", + "type": "boolean", + "example": true + }, + "system_prompt": { + "description": "Optional system prompt override", + "type": "string", + "example": "You are a helpful coding assistant" + }, + "working_directory": { + "description": "Working directory for the command", + "type": "string", + "example": "/workspace/my-project" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentCompletionResponse": { + "type": "object", + "properties": { + "agent_type": { + "description": "Agent that generated this response", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "error": { + "description": "Any error that occurred", + "type": "string" + }, + "is_chunk": { + "description": "Whether this is a streaming chunk or complete response", + "type": "boolean", + "example": false + }, + "is_last": { + "description": "Whether this is the last chunk in a stream", + "type": "boolean", + "example": true + }, + "response": { + "description": "The generated response text", + "type": "string", + "example": "I can help you debug that error..." + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentEvent": { + "type": "object", + "properties": { + "agent_type": { + "description": "Agent type that generated this event", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "data": { + "description": "Additional event-specific data", + "type": "object", + "additionalProperties": true + }, + "event_type": { + "description": "Type of the event (UserPromptSubmit, Stop, etc.)", + "type": "string", + "example": "UserPromptSubmit" + }, + "session_id": { + "description": "Session ID if available", + "type": "string", + "example": "abc123-def456-ghi789" + }, + "timestamp": { + "description": "Timestamp of the event", + "type": "string" + }, + "working_directory": { + "description": "Working directory where the event occurred", + "type": "string", + "example": "/workspace/my-project" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentFullSessionData": { + "type": "object", + "properties": { + "allSessions": { + "description": "All sessions available for this workspace", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionListEntry" + } + }, + "messageCount": { + "description": "Total message count in full data", + "type": "integer" + }, + "messages": { + "description": "Full conversation history (only when full=true)", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionMessage" + } + }, + "sessionInfo": { + "description": "Basic session information", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary" + } + ] + }, + "userPrompts": { + "description": "User prompts/history (agent-specific format)", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentHistoryEntry" + } + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentHistoryEntry": { + "type": "object", + "properties": { + "data": { + "description": "Agent-specific data", + "type": "object", + "additionalProperties": true + }, + "display": { + "type": "string" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSessionListEntry": { + "type": "object", + "properties": { + "endTime": { + "description": "When the session ended (if available)", + "type": "string", + "example": "2024-01-15T16:45:30Z" + }, + "isActive": { + "description": "Whether this session is currently active", + "type": "boolean", + "example": false + }, + "lastModified": { + "description": "When the session was last modified", + "type": "string", + "example": "2024-01-15T16:45:30Z" + }, + "sessionId": { + "description": "Unique session identifier", + "type": "string", + "example": "abc123-def456-ghi789" + }, + "startTime": { + "description": "When the session started (if available)", + "type": "string", + "example": "2024-01-15T14:30:00Z" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSessionMessage": { + "type": "object", + "properties": { + "agentType": { + "description": "Agent-specific message data", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "content": { + "description": "Raw agent-specific content", + "type": "object", + "additionalProperties": true + }, + "timestamp": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSessionSummary": { + "type": "object", + "properties": { + "agentType": { + "description": "Agent type", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ], + "example": "claude" + }, + "allSessions": { + "description": "List of all available sessions for this worktree", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionListEntry" + } + }, + "currentSessionId": { + "description": "ID of the currently active session", + "type": "string", + "example": "xyz789-ghi012" + }, + "header": { + "description": "Header/title of the session", + "type": "string", + "example": "Fix bug in user authentication" + }, + "isActive": { + "description": "Whether this session is currently active", + "type": "boolean", + "example": true + }, + "lastCost": { + "description": "Metrics (from completed sessions)\nCost in USD of the last completed session (agent-specific)", + "type": "number", + "example": 0.25 + }, + "lastDuration": { + "description": "Duration in seconds of the last session", + "type": "integer", + "example": 3600 + }, + "lastSessionId": { + "description": "ID of the most recent completed session", + "type": "string", + "example": "abc123-def456" + }, + "lastTotalInputTokens": { + "description": "Total input tokens used in the last session (agent-specific)", + "type": "integer", + "example": 15000 + }, + "lastTotalOutputTokens": { + "description": "Total output tokens generated in the last session (agent-specific)", + "type": "integer", + "example": 8500 + }, + "sessionEndTime": { + "description": "When the last session ended (if not active)", + "type": "string", + "example": "2024-01-15T16:45:30Z" + }, + "sessionStartTime": { + "description": "When the current session started", + "type": "string", + "example": "2024-01-15T14:30:00Z" + }, + "turnCount": { + "description": "Number of conversation turns in the session", + "type": "integer", + "example": 15 + }, + "worktreePath": { + "description": "Path to the worktree directory", + "type": "string", + "example": "/workspace/my-project" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSettings": { + "type": "object", + "properties": { + "agentSpecificSettings": { + "description": "Agent-specific settings", + "type": "object", + "additionalProperties": true + }, + "agentType": { + "description": "Agent type", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "hasCompletedOnboarding": { + "description": "Whether user has completed onboarding", + "type": "boolean", + "example": true + }, + "isAuthenticated": { + "description": "Whether user is authenticated", + "type": "boolean", + "example": true + }, + "notificationsEnabled": { + "description": "Whether notifications are enabled", + "type": "boolean", + "example": true + }, + "numStartups": { + "description": "Number of times agent has been started", + "type": "integer", + "example": 15 + }, + "version": { + "description": "Version information", + "type": "string", + "example": "1.2.3" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSettingsUpdateRequest": { + "type": "object", + "properties": { + "agentSpecificSettings": { + "description": "Agent-specific settings updates", + "type": "object", + "additionalProperties": true + }, + "notificationsEnabled": { + "description": "Whether notifications should be enabled", + "type": "boolean", + "example": true + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentType": { + "type": "string", + "enum": [ + "claude", + "codex" + ], + "x-enum-varnames": [ + "AgentTypeClaude", + "AgentTypeCodex" + ] + }, "github_com_vanpelt_catnip_internal_models.ClaudeActivityState": { "type": "string", "enum": [ diff --git a/container/docs/swagger.json b/container/docs/swagger.json index 3528f0ef..ef7f6c22 100644 --- a/container/docs/swagger.json +++ b/container/docs/swagger.json @@ -12,19 +12,22 @@ }, "host": "localhost:6369", "paths": { - "/v1/auth/github/reset": { - "post": { - "description": "Clears any active authentication process", + "/v1/agents": { + "get": { + "description": "Returns a list of all registered coding agents", + "produces": [ + "application/json" + ], "tags": [ - "auth" + "agents" ], - "summary": "Reset authentication state", + "summary": "Get available agents", "responses": { "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { + "type": "array", + "items": { "type": "string" } } @@ -32,43 +35,9 @@ } } }, - "/v1/auth/github/start": { - "post": { - "description": "Initiates GitHub device flow authentication", - "tags": [ - "auth" - ], - "summary": "Start GitHub authentication", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/internal_handlers.AuthStartResponse" - } - } - } - } - }, - "/v1/auth/github/status": { - "get": { - "description": "Returns the current status of the authentication flow", - "tags": [ - "auth" - ], - "summary": "Get authentication status", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/internal_handlers.AuthStatusResponse" - } - } - } - } - }, - "/v1/claude/hooks": { + "/v1/agents/events": { "post": { - "description": "Receives hook notifications from Claude Code for activity tracking", + "description": "Receives event notifications from coding agents for activity tracking", "consumes": [ "application/json" ], @@ -76,17 +45,17 @@ "application/json" ], "tags": [ - "claude" + "agents" ], - "summary": "Handle Claude hook events", + "summary": "Handle agent events", "parameters": [ { - "description": "Claude hook event", + "description": "Agent event", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeHookEvent" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentEvent" } } ], @@ -103,14 +72,14 @@ } } }, - "/v1/claude/latest-message": { + "/v1/agents/latest-message": { "get": { - "description": "Returns the most recent assistant message from Claude Code session for a specific worktree", + "description": "Returns the most recent assistant message from any coding agent session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get worktree latest assistant message", "parameters": [ @@ -127,17 +96,15 @@ "description": "OK", "schema": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": true } } } } }, - "/v1/claude/messages": { + "/v1/agents/messages": { "post": { - "description": "Creates a completion using the claude CLI tool as a subprocess, supporting both streaming and non-streaming responses, with resume functionality", + "description": "Creates a completion using any registered coding agent, supporting both streaming and non-streaming responses, with resume functionality", "consumes": [ "application/json" ], @@ -145,9 +112,9 @@ "application/json" ], "tags": [ - "claude" + "agents" ], - "summary": "Create Claude messages using CLI", + "summary": "Create agent messages", "parameters": [ { "description": "Create completion request", @@ -155,7 +122,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionRequest" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentCompletionRequest" } } ], @@ -163,7 +130,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentCompletionResponse" } }, "400": { @@ -187,14 +154,14 @@ } } }, - "/v1/claude/session": { + "/v1/agents/session": { "get": { - "description": "Returns Claude Code session metadata for a specific worktree", + "description": "Returns coding agent session metadata for a specific worktree", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get worktree session summary", "parameters": [ @@ -204,26 +171,32 @@ "name": "worktree_path", "in": "query", "required": true + }, + { + "type": "string", + "description": "Agent type (defaults to Claude for backward compatibility)", + "name": "agent_type", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary" } } } } }, - "/v1/claude/session/{uuid}": { + "/v1/agents/session/{uuid}": { "get": { "description": "Returns complete session data including all messages for a specific session UUID", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get session by UUID", "parameters": [ @@ -239,20 +212,20 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentFullSessionData" } } } } }, - "/v1/claude/sessions": { + "/v1/agents/sessions": { "get": { - "description": "Returns Claude Code session metadata for all worktrees with Claude data", + "description": "Returns coding agent session metadata for all worktrees with agent data", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get all worktree session summaries", "responses": { @@ -261,34 +234,42 @@ "schema": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary" } } } } } }, - "/v1/claude/settings": { + "/v1/agents/settings": { "get": { - "description": "Returns Claude Code configuration settings including theme, authentication status, and other metadata", + "description": "Returns coding agent configuration settings including authentication status and other metadata", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" + ], + "summary": "Get agent settings", + "parameters": [ + { + "type": "string", + "description": "Agent type (defaults to Claude)", + "name": "agent_type", + "in": "query" + } ], - "summary": "Get Claude settings", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettings" } } } }, "put": { - "description": "Updates Claude Code configuration settings (theme and notifications)", + "description": "Updates coding agent configuration settings", "consumes": [ "application/json" ], @@ -296,17 +277,23 @@ "application/json" ], "tags": [ - "claude" + "agents" ], - "summary": "Update Claude settings", + "summary": "Update agent settings", "parameters": [ + { + "type": "string", + "description": "Agent type (defaults to Claude)", + "name": "agent_type", + "in": "query" + }, { "description": "Settings update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettingsUpdateRequest" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettingsUpdateRequest" } } ], @@ -314,20 +301,20 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettings" } } } } }, - "/v1/claude/todos": { + "/v1/agents/todos": { "get": { - "description": "Returns the most recent TodoWrite structure from Claude Code session for a specific worktree", + "description": "Returns the most recent TodoWrite structure from any coding agent session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "claude" + "agents" ], "summary": "Get worktree todos", "parameters": [ @@ -352,64 +339,63 @@ } } }, - "/v1/events": { - "get": { - "description": "Streams real-time events in Server-Sent Events format. Events include port changes, git status, processes, and container status updates.\n\n## Event Types\n\n### Port Events\n- **port:opened**: Fired when a new port is detected\n- `port` (int): Port number\n- `service` (string): Service type (http, tcp)\n- `protocol` (string): Protocol used\n- `title` (string): Service title/name if detected\n- **port:closed**: Fired when a port is no longer available\n- `port` (int): Port number that was closed\n\n### Git Events\n- **git:dirty**: Fired when git workspace has uncommitted changes\n- `workspace` (string): Workspace path\n- `files` ([]string): List of modified files\n- **git:clean**: Fired when git workspace becomes clean\n- `workspace` (string): Workspace path\n\n### Process Events\n- **process:started**: Fired when a new process starts\n- `pid` (int): Process ID\n- `command` (string): Command that was executed\n- `workspace` (string): Workspace where process started\n- **process:stopped**: Fired when a process terminates\n- `pid` (int): Process ID that stopped\n- `exitCode` (int): Exit code of the process\n\n### Container Events\n- **container:status**: Fired when container status changes\n- `status` (string): Container status (running, stopped, error)\n- `message` (string): Optional status message\n\n### System Events\n- **heartbeat**: Sent every 5 seconds to keep connection alive\n- `timestamp` (int64): Current timestamp in milliseconds\n- `uptime` (int64): Server uptime in milliseconds\n\n## Message Format\nEach SSE message is a JSON object with:\n- `event`: Event object containing `type` and `payload`\n- `timestamp`: Event timestamp in milliseconds\n- `id`: Unique event identifier\n\n## Connection Behavior\n- Auto-reconnects on disconnection\n- Sends current state on initial connection\n- Heartbeat every 5 seconds\n- Rate limited to prevent spam", - "consumes": [ - "text/event-stream" - ], - "produces": [ - "text/event-stream" - ], + "/v1/auth/github/reset": { + "post": { + "description": "Clears any active authentication process", "tags": [ - "events" + "auth" ], - "summary": "Server-Sent Events endpoint for real-time container events", + "summary": "Reset authentication state", "responses": { "200": { - "description": "SSE stream of events", + "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.SSEMessage" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/git/branches/{repo_id}": { - "get": { - "description": "Returns a list of remote branches for a specific repository", - "produces": [ - "application/json" - ], + "/v1/auth/github/start": { + "post": { + "description": "Initiates GitHub device flow authentication", "tags": [ - "git" + "auth" ], - "summary": "Get repository branches", - "parameters": [ - { - "type": "string", - "description": "Repository ID", - "name": "repo_id", - "in": "path", - "required": true + "summary": "Start GitHub authentication", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthStartResponse" + } } + } + } + }, + "/v1/auth/github/status": { + "get": { + "description": "Returns the current status of the authentication flow", + "tags": [ + "auth" ], + "summary": "Get authentication status", "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.AuthStatusResponse" } } } } }, - "/v1/git/checkout/{org}/{repo}": { + "/v1/claude/hooks": { "post": { - "description": "Clones a GitHub repository as a bare repo and creates initial worktree", + "description": "Receives hook notifications from Claude Code for activity tracking", "consumes": [ "application/json" ], @@ -417,93 +403,98 @@ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Checkout a GitHub repository", + "summary": "Handle Claude hook events", "parameters": [ { - "type": "string", - "description": "Organization name", - "name": "org", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Repository name", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Branch name (optional)", - "name": "branch", - "in": "query" + "description": "Claude hook event", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeHookEvent" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.CheckoutResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/git/github/repos": { + "/v1/claude/latest-message": { "get": { - "description": "Returns a list of GitHub repositories accessible to the authenticated user", + "description": "Returns the most recent assistant message from Claude Code session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "git" + "claude" + ], + "summary": "Get worktree latest assistant message", + "parameters": [ + { + "type": "string", + "description": "Worktree path", + "name": "worktree_path", + "in": "query", + "required": true + } ], - "summary": "List GitHub repositories", "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/internal_handlers.GitHubRepository" + "type": "object", + "additionalProperties": { + "type": "string" } } } } } }, - "/v1/git/repositories/{id}": { - "delete": { - "description": "Removes a repository and all its associated worktrees from disk and state management", + "/v1/claude/messages": { + "post": { + "description": "Creates a completion using the claude CLI tool as a subprocess, supporting both streaming and non-streaming responses, with resume functionality", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Delete repository", + "summary": "Create Claude messages using CLI", "parameters": [ { - "type": "string", - "description": "Repository ID", - "name": "id", - "in": "path", - "required": true + "description": "Create completion request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.CreateCompletionResponse" } }, "400": { - "description": "Repository not found or deletion failed", + "description": "Bad Request", "schema": { "type": "object", "additionalProperties": { @@ -512,7 +503,7 @@ } }, "500": { - "description": "Internal server error", + "description": "Internal Server Error", "schema": { "type": "object", "additionalProperties": { @@ -523,88 +514,108 @@ } } }, - "/v1/git/repositories/{id}/github": { - "post": { - "description": "Creates a new GitHub repository and sets it as the origin for a local repository", - "consumes": [ + "/v1/claude/session": { + "get": { + "description": "Returns Claude Code session metadata for a specific worktree", + "produces": [ "application/json" ], + "tags": [ + "claude" + ], + "summary": "Get worktree session summary", + "parameters": [ + { + "type": "string", + "description": "Worktree path", + "name": "worktree_path", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" + } + } + } + } + }, + "/v1/claude/session/{uuid}": { + "get": { + "description": "Returns complete session data including all messages for a specific session UUID", "produces": [ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Create GitHub repository", + "summary": "Get session by UUID", "parameters": [ { "type": "string", - "description": "Repository ID", - "name": "id", + "description": "Session UUID", + "name": "uuid", "in": "path", "required": true - }, - { - "description": "Repository creation request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryRequest" - } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryResponse" - } - }, - "400": { - "description": "Invalid request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" } - }, - "500": { - "description": "Internal server error", + } + } + } + }, + "/v1/claude/sessions": { + "get": { + "description": "Returns Claude Code session metadata for all worktrees with Claude data", + "produces": [ + "application/json" + ], + "tags": [ + "claude" + ], + "summary": "Get all worktree session summaries", + "responses": { + "200": { + "description": "OK", "schema": { "type": "object", "additionalProperties": { - "type": "string" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary" } } } } } }, - "/v1/git/status": { + "/v1/claude/settings": { "get": { - "description": "Returns the current repository and worktree status", + "description": "Returns Claude Code configuration settings including theme, authentication status, and other metadata", "produces": [ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Get Git status", + "summary": "Get Claude settings", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.GitStatus" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" } } } - } - }, - "/v1/git/template": { - "post": { - "description": "Creates a new Git repository and workspace from a predefined project template", + }, + "put": { + "description": "Updates Claude Code configuration settings (theme and notifications)", "consumes": [ "application/json" ], @@ -612,17 +623,17 @@ "application/json" ], "tags": [ - "git" + "claude" ], - "summary": "Create workspace from template", + "summary": "Update Claude settings", "parameters": [ { - "description": "Template creation request", + "description": "Settings update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_handlers.CreateTemplateRequest" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettingsUpdateRequest" } } ], @@ -630,92 +641,82 @@ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "400": { - "description": "Invalid request or template not found", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal server error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSettings" } } } } }, - "/v1/git/worktrees": { + "/v1/claude/todos": { "get": { - "description": "Returns a list of all worktrees for the current repository with fast cache-enhanced responses", + "description": "Returns the most recent TodoWrite structure from Claude Code session for a specific worktree", "produces": [ "application/json" ], "tags": [ - "git" + "claude" + ], + "summary": "Get worktree todos", + "parameters": [ + { + "type": "string", + "description": "Worktree path", + "name": "worktree_path", + "in": "query", + "required": true + } ], - "summary": "List all worktrees", "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/internal_handlers.EnhancedWorktree" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Todo" } } } } } }, - "/v1/git/worktrees/cleanup": { - "post": { - "description": "Removes worktrees that have been fully merged into their source branch", + "/v1/events": { + "get": { + "description": "Streams real-time events in Server-Sent Events format. Events include port changes, git status, processes, and container status updates.\n\n## Event Types\n\n### Port Events\n- **port:opened**: Fired when a new port is detected\n- `port` (int): Port number\n- `service` (string): Service type (http, tcp)\n- `protocol` (string): Protocol used\n- `title` (string): Service title/name if detected\n- **port:closed**: Fired when a port is no longer available\n- `port` (int): Port number that was closed\n\n### Git Events\n- **git:dirty**: Fired when git workspace has uncommitted changes\n- `workspace` (string): Workspace path\n- `files` ([]string): List of modified files\n- **git:clean**: Fired when git workspace becomes clean\n- `workspace` (string): Workspace path\n\n### Process Events\n- **process:started**: Fired when a new process starts\n- `pid` (int): Process ID\n- `command` (string): Command that was executed\n- `workspace` (string): Workspace where process started\n- **process:stopped**: Fired when a process terminates\n- `pid` (int): Process ID that stopped\n- `exitCode` (int): Exit code of the process\n\n### Container Events\n- **container:status**: Fired when container status changes\n- `status` (string): Container status (running, stopped, error)\n- `message` (string): Optional status message\n\n### System Events\n- **heartbeat**: Sent every 5 seconds to keep connection alive\n- `timestamp` (int64): Current timestamp in milliseconds\n- `uptime` (int64): Server uptime in milliseconds\n\n## Message Format\nEach SSE message is a JSON object with:\n- `event`: Event object containing `type` and `payload`\n- `timestamp`: Event timestamp in milliseconds\n- `id`: Unique event identifier\n\n## Connection Behavior\n- Auto-reconnects on disconnection\n- Sends current state on initial connection\n- Heartbeat every 5 seconds\n- Rate limited to prevent spam", + "consumes": [ + "text/event-stream" + ], "produces": [ - "application/json" + "text/event-stream" ], "tags": [ - "git" + "events" ], - "summary": "Cleanup merged worktrees", + "summary": "Server-Sent Events endpoint for real-time container events", "responses": { "200": { - "description": "OK", + "description": "SSE stream of events", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/internal_handlers.SSEMessage" } } } } }, - "/v1/git/worktrees/{id}": { - "delete": { - "description": "Removes a worktree from the repository", + "/v1/git/branches/{repo_id}": { + "get": { + "description": "Returns a list of remote branches for a specific repository", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Delete worktree", + "summary": "Get repository branches", "parameters": [ { "type": "string", - "description": "Worktree ID", - "name": "id", + "description": "Repository ID", + "name": "repo_id", "in": "path", "required": true } @@ -724,13 +725,18 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "type": "array", + "items": { + "type": "string" + } } } } - }, - "patch": { - "description": "Updates specific fields of a worktree (for testing purposes)", + } + }, + "/v1/git/checkout/{org}/{repo}": { + "post": { + "description": "Clones a GitHub repository as a bare repo and creates initial worktree", "consumes": [ "application/json" ], @@ -740,92 +746,79 @@ "tags": [ "git" ], - "summary": "Update worktree fields", + "summary": "Checkout a GitHub repository", "parameters": [ { "type": "string", - "description": "Worktree ID", - "name": "id", + "description": "Organization name", + "name": "org", "in": "path", "required": true }, { - "description": "Fields to update", - "name": "updates", - "in": "body", - "required": true, - "schema": { - "type": "object" - } + "type": "string", + "description": "Repository name", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Branch name (optional)", + "name": "branch", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Worktree" + "$ref": "#/definitions/internal_handlers.CheckoutResponse" } } } } }, - "/v1/git/worktrees/{id}/diff": { + "/v1/git/github/repos": { "get": { - "description": "Returns the diff for a worktree against its source branch, including all staged/unstaged changes", + "description": "Returns a list of GitHub repositories accessible to the authenticated user", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Get worktree diff", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "List GitHub repositories", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeDiffResponse" + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers.GitHubRepository" + } } } } } }, - "/v1/git/worktrees/{id}/graduate": { - "post": { - "description": "Triggers renaming of any branch to a semantic name using Claude or a custom name", - "consumes": [ - "application/json" - ], + "/v1/git/repositories/{id}": { + "delete": { + "description": "Removes a repository and all its associated worktrees from disk and state management", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Rename branch", + "summary": "Delete repository", "parameters": [ { "type": "string", - "description": "Worktree ID", + "description": "Repository ID", "name": "id", "in": "path", "required": true - }, - { - "description": "Graduation request with optional custom branch name", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/internal_handlers.GraduateBranchRequest" - } } ], "responses": { @@ -833,31 +826,11 @@ "description": "OK", "schema": { "type": "object", - "additionalProperties": { - "type": "string" - } + "additionalProperties": true } }, "400": { - "description": "Bad request (invalid branch name, branch already exists, etc.)", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "404": { - "description": "Worktree not found", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "422": { - "description": "No title available for automatic naming", + "description": "Repository not found or deletion failed", "schema": { "type": "object", "additionalProperties": { @@ -877,9 +850,9 @@ } } }, - "/v1/git/worktrees/{id}/merge": { + "/v1/git/repositories/{id}/github": { "post": { - "description": "Merges a local repo worktree's changes back to the main repository", + "description": "Creates a new GitHub repository and sets it as the origin for a local repository", "consumes": [ "application/json" ], @@ -889,24 +862,22 @@ "tags": [ "git" ], - "summary": "Merge worktree to main", + "summary": "Create GitHub repository", "parameters": [ { "type": "string", - "description": "Worktree ID", + "description": "Repository ID", "name": "id", "in": "path", "required": true }, { - "description": "Merge options", - "name": "body", + "description": "Repository creation request", + "name": "request", "in": "body", + "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryRequest" } } ], @@ -914,71 +885,53 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "$ref": "#/definitions/internal_handlers.CreateGitHubRepositoryResponse" } - } - } - } - }, - "/v1/git/worktrees/{id}/merge/check": { - "get": { - "description": "Checks if merging a worktree to main would cause conflicts", - "produces": [ - "application/json" - ], - "tags": [ - "git" - ], - "summary": "Check merge conflicts", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", + }, + "400": { + "description": "Invalid request", "schema": { - "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/git/worktrees/{id}/pr": { + "/v1/git/status": { "get": { - "description": "Gets information about an existing pull request for a worktree branch", + "description": "Returns the current repository and worktree status", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Get pull request info", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "Get Git status", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestInfo" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.GitStatus" } } } - }, - "put": { - "description": "Updates an existing pull request for a worktree branch", + } + }, + "/v1/git/template": { + "post": { + "description": "Creates a new Git repository and workspace from a predefined project template", "consumes": [ "application/json" ], @@ -988,22 +941,15 @@ "tags": [ "git" ], - "summary": "Update pull request", + "summary": "Create workspace from template", "parameters": [ { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Pull request details", + "description": "Template creation request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" + "$ref": "#/definitions/internal_handlers.CreateTemplateRequest" } } ], @@ -1011,90 +957,87 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid request or template not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } } - }, - "post": { - "description": "Creates a pull request for a worktree branch", - "consumes": [ - "application/json" - ], + } + }, + "/v1/git/worktrees": { + "get": { + "description": "Returns a list of all worktrees for the current repository with fast cache-enhanced responses", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Create pull request", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Pull request details", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" - } - } - ], + "summary": "List all worktrees", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers.EnhancedWorktree" + } } } } } }, - "/v1/git/worktrees/{id}/preview": { + "/v1/git/worktrees/cleanup": { "post": { - "description": "Creates a preview branch in the main repo for viewing changes outside container", + "description": "Removes worktrees that have been fully merged into their source branch", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Create worktree preview", - "parameters": [ - { - "type": "string", - "description": "Worktree ID", - "name": "id", - "in": "path", - "required": true - } - ], + "summary": "Cleanup merged worktrees", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "type": "object", + "additionalProperties": true } } } } }, - "/v1/git/worktrees/{id}/refresh": { - "post": { - "description": "Forces an immediate refresh of a worktree's cached status including commit counts", + "/v1/git/worktrees/{id}": { + "delete": { + "description": "Removes a worktree from the repository", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Force refresh worktree status", + "summary": "Delete worktree", "parameters": [ { "type": "string", @@ -1108,18 +1051,13 @@ "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" } } } - } - }, - "/v1/git/worktrees/{id}/sync": { - "post": { - "description": "Syncs a worktree with its source branch using merge or rebase strategy", + }, + "patch": { + "description": "Updates specific fields of a worktree (for testing purposes)", "consumes": [ "application/json" ], @@ -1129,7 +1067,7 @@ "tags": [ "git" ], - "summary": "Sync worktree with source branch", + "summary": "Update worktree fields", "parameters": [ { "type": "string", @@ -1139,15 +1077,12 @@ "required": true }, { - "description": "Sync options", - "name": "body", + "description": "Fields to update", + "name": "updates", "in": "body", "required": true, "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "type": "object" } } ], @@ -1155,22 +1090,22 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Worktree" } } } } }, - "/v1/git/worktrees/{id}/sync/check": { + "/v1/git/worktrees/{id}/diff": { "get": { - "description": "Checks if syncing a worktree would cause merge conflicts", + "description": "Returns the diff for a worktree against its source branch, including all staged/unstaged changes", "produces": [ "application/json" ], "tags": [ "git" ], - "summary": "Check sync conflicts", + "summary": "Get worktree diff", "parameters": [ { "type": "string", @@ -1184,15 +1119,15 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" + "$ref": "#/definitions/internal_handlers.WorktreeDiffResponse" } } } } }, - "/v1/notifications": { + "/v1/git/worktrees/{id}/graduate": { "post": { - "description": "Sends a notification event to all connected SSE clients, including the TUI app which can display native macOS notifications", + "description": "Triggers renaming of any branch to a semantic name using Claude or a custom name", "consumes": [ "application/json" ], @@ -1200,23 +1135,29 @@ "application/json" ], "tags": [ - "notifications" + "git" ], - "summary": "Send notification", + "summary": "Rename branch", "parameters": [ { - "description": "Notification details", - "name": "notification", + "type": "string", + "description": "Worktree ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Graduation request with optional custom branch name", + "name": "request", "in": "body", - "required": true, "schema": { - "$ref": "#/definitions/internal_handlers.NotificationPayload" + "$ref": "#/definitions/internal_handlers.GraduateBranchRequest" } } ], "responses": { "200": { - "description": "Success response", + "description": "OK", "schema": { "type": "object", "additionalProperties": { @@ -1225,71 +1166,25 @@ } }, "400": { - "description": "Bad request", + "description": "Bad request (invalid branch name, branch already exists, etc.)", "schema": { "type": "object", "additionalProperties": { "type": "string" } } - } - } - } - }, - "/v1/ports": { - "get": { - "description": "Returns a list of all currently detected ports with their service information", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ports" - ], - "summary": "Get detected ports", - "responses": { - "200": { - "description": "List of detected ports and services", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/v1/ports/mappings": { - "post": { - "description": "Records a mapping from container port to host port and broadcasts an SSE event", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ports" - ], - "summary": "Set host port mapping for a container port", - "parameters": [ - { - "description": "Mapping object with 'port' and 'host_port'", - "name": "mapping", - "in": "body", - "required": true, + }, + "404": { + "description": "Worktree not found", "schema": { "type": "object", "additionalProperties": { - "type": "integer" + "type": "string" } } - } - ], - "responses": { - "200": { - "description": "Mapping set", + }, + "422": { + "description": "No title available for automatic naming", "schema": { "type": "object", "additionalProperties": { @@ -1297,8 +1192,8 @@ } } }, - "400": { - "description": "Invalid request", + "500": { + "description": "Internal server error", "schema": { "type": "object", "additionalProperties": { @@ -1309,9 +1204,9 @@ } } }, - "/v1/ports/mappings/{port}": { - "delete": { - "description": "Removes a mapping and broadcasts an SSE event with host_port=0", + "/v1/git/worktrees/{id}/merge": { + "post": { + "description": "Merges a local repo worktree's changes back to the main repository", "consumes": [ "application/json" ], @@ -1319,230 +1214,219 @@ "application/json" ], "tags": [ - "ports" + "git" ], - "summary": "Delete host port mapping for a container port", + "summary": "Merge worktree to main", "parameters": [ { - "type": "integer", - "description": "Container port", - "name": "port", + "type": "string", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true - } - ], - "responses": { - "200": { - "description": "Mapping deleted", + }, + { + "description": "Merge options", + "name": "body", + "in": "body", "schema": { "type": "object", "additionalProperties": { "type": "string" } } - }, - "400": { - "description": "Invalid port", + } + ], + "responses": { + "200": { + "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" } } } } }, - "/v1/ports/{port}": { + "/v1/git/worktrees/{id}/merge/check": { "get": { - "description": "Returns detailed information about a specific port if it exists", - "consumes": [ - "application/json" - ], + "description": "Checks if merging a worktree to main would cause conflicts", "produces": [ "application/json" ], "tags": [ - "ports" + "git" ], - "summary": "Get port information", + "summary": "Check merge conflicts", "parameters": [ { - "type": "integer", - "description": "Port number", - "name": "port", + "type": "string", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true } ], "responses": { "200": { - "description": "Port information", - "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_services.ServiceInfo" - } - }, - "404": { - "description": "Port not found", + "description": "OK", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" } } } } }, - "/v1/pty": { + "/v1/git/worktrees/{id}/pr": { "get": { - "description": "Establishes a WebSocket connection for terminal access", + "description": "Gets information about an existing pull request for a worktree branch", + "produces": [ + "application/json" + ], "tags": [ - "pty" + "git" ], - "summary": "Create PTY WebSocket connection", + "summary": "Get pull request info", "parameters": [ { "type": "string", - "description": "Session ID", - "name": "session", - "in": "query", + "description": "Worktree ID", + "name": "id", + "in": "path", "required": true } ], "responses": { - "101": { - "description": "Switching Protocols", + "200": { + "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestInfo" } } } - } - }, - "/v1/pty/sse": { - "get": { - "description": "Establishes a Server-Sent Events connection for terminal streaming", + }, + "put": { + "description": "Updates an existing pull request for a worktree branch", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "pty" + "git" ], - "summary": "Create PTY SSE connection", + "summary": "Update pull request", "parameters": [ { "type": "string", - "description": "Session ID", - "name": "session", - "in": "query", + "description": "Worktree ID", + "name": "id", + "in": "path", "required": true }, { - "type": "string", - "description": "Agent type (claude, bash, etc)", - "name": "agent", - "in": "query" - }, - { - "type": "string", - "description": "Prompt to send to PTY session", - "name": "prompt", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Stream of terminal events" + "description": "Pull request details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" + } } - } - } - }, - "/v1/sessions": { - "get": { - "description": "Returns all sessions including ended ones", - "produces": [ - "application/json" ], - "tags": [ - "sessions" - ], - "summary": "Get all sessions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.SessionsResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" } } } - } - }, - "/v1/sessions/active": { - "get": { - "description": "Returns all active sessions (not ended)", + }, + "post": { + "description": "Creates a pull request for a worktree branch", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "sessions" + "git" + ], + "summary": "Create pull request", + "parameters": [ + { + "type": "string", + "description": "Worktree ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Pull request details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreatePullRequestRequest" + } + } ], - "summary": "Get active sessions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.SessionsResponse" + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.PullRequestResponse" } } } } }, - "/v1/sessions/workspace/{workspace}": { - "get": { - "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", + "/v1/git/worktrees/{id}/preview": { + "post": { + "description": "Creates a preview branch in the main repo for viewing changes outside container", "produces": [ "application/json" ], "tags": [ - "sessions" + "git" ], - "summary": "Get session by workspace", + "summary": "Create worktree preview", "parameters": [ { "type": "string", - "description": "Workspace directory path", - "name": "workspace", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true - }, - { - "type": "boolean", - "description": "Include full session data with messages and user prompts", - "name": "full", - "in": "query" } ], "responses": { "200": { - "description": "Basic session info when full=false", + "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.ActiveSessionInfo" + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" } } } - }, - "delete": { - "description": "Removes a session from the active sessions mapping", + } + }, + "/v1/git/worktrees/{id}/refresh": { + "post": { + "description": "Forces an immediate refresh of a worktree's cached status including commit counts", "produces": [ "application/json" ], "tags": [ - "sessions" + "git" ], - "summary": "Delete session", + "summary": "Force refresh worktree status", "parameters": [ { "type": "string", - "description": "Workspace directory path", - "name": "workspace", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true } @@ -1551,34 +1435,74 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_handlers.DeleteSessionResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/v1/sessions/workspace/{workspace}/session/{sessionId}": { - "get": { - "description": "Returns complete session data for a specific session ID within a workspace", + "/v1/git/worktrees/{id}/sync": { + "post": { + "description": "Syncs a worktree with its source branch using merge or rebase strategy", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "sessions" + "git" ], - "summary": "Get session by ID", + "summary": "Sync worktree with source branch", "parameters": [ { "type": "string", - "description": "Workspace directory path", - "name": "workspace", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true }, + { + "description": "Sync options", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.WorktreeOperationResponse" + } + } + } + } + }, + "/v1/git/worktrees/{id}/sync/check": { + "get": { + "description": "Checks if syncing a worktree would cause merge conflicts", + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Check sync conflicts", + "parameters": [ { "type": "string", - "description": "Session ID (UUID)", - "name": "sessionId", + "description": "Worktree ID", + "name": "id", "in": "path", "required": true } @@ -1587,58 +1511,835 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" + "$ref": "#/definitions/internal_handlers.ConflictCheckResponse" } } } } }, - "/v1/upload": { + "/v1/notifications": { "post": { - "description": "Upload a file to /tmp/uploads directory with automatic conflict resolution", + "description": "Sends a notification event to all connected SSE clients, including the TUI app which can display native macOS notifications", "consumes": [ - "multipart/form-data" + "application/json" ], "produces": [ "application/json" ], "tags": [ - "upload" + "notifications" ], - "summary": "Upload a file", + "summary": "Send notification", "parameters": [ { - "type": "file", - "description": "File to upload", - "name": "file", - "in": "formData", - "required": true + "description": "Notification details", + "name": "notification", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.NotificationPayload" + } } ], "responses": { "200": { - "description": "OK", + "description": "Success response", "schema": { - "$ref": "#/definitions/internal_handlers.UploadResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "400": { - "description": "Bad Request", + "description": "Bad request", "schema": { - "$ref": "#/definitions/internal_handlers.UploadResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/v1/ports": { + "get": { + "description": "Returns a list of all currently detected ports with their service information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Get detected ports", + "responses": { + "200": { + "description": "List of detected ports and services", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/v1/ports/mappings": { + "post": { + "description": "Records a mapping from container port to host port and broadcasts an SSE event", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Set host port mapping for a container port", + "parameters": [ + { + "description": "Mapping object with 'port' and 'host_port'", + "name": "mapping", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + ], + "responses": { + "200": { + "description": "Mapping set", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "500": { - "description": "Internal Server Error", + "400": { + "description": "Invalid request", "schema": { - "$ref": "#/definitions/internal_handlers.UploadResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } - } - }, - "definitions": { + }, + "/v1/ports/mappings/{port}": { + "delete": { + "description": "Removes a mapping and broadcasts an SSE event with host_port=0", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Delete host port mapping for a container port", + "parameters": [ + { + "type": "integer", + "description": "Container port", + "name": "port", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Mapping deleted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid port", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/v1/ports/{port}": { + "get": { + "description": "Returns detailed information about a specific port if it exists", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ports" + ], + "summary": "Get port information", + "parameters": [ + { + "type": "integer", + "description": "Port number", + "name": "port", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Port information", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_services.ServiceInfo" + } + }, + "404": { + "description": "Port not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/v1/pty": { + "get": { + "description": "Establishes a WebSocket connection for terminal access", + "tags": [ + "pty" + ], + "summary": "Create PTY WebSocket connection", + "parameters": [ + { + "type": "string", + "description": "Session ID", + "name": "session", + "in": "query", + "required": true + } + ], + "responses": { + "101": { + "description": "Switching Protocols", + "schema": { + "type": "string" + } + } + } + } + }, + "/v1/pty/sse": { + "get": { + "description": "Establishes a Server-Sent Events connection for terminal streaming", + "tags": [ + "pty" + ], + "summary": "Create PTY SSE connection", + "parameters": [ + { + "type": "string", + "description": "Session ID", + "name": "session", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Agent type (claude, bash, etc)", + "name": "agent", + "in": "query" + }, + { + "type": "string", + "description": "Prompt to send to PTY session", + "name": "prompt", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Stream of terminal events" + } + } + } + }, + "/v1/sessions": { + "get": { + "description": "Returns all sessions including ended ones", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get all sessions", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SessionsResponse" + } + } + } + } + }, + "/v1/sessions/active": { + "get": { + "description": "Returns all active sessions (not ended)", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get active sessions", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SessionsResponse" + } + } + } + } + }, + "/v1/sessions/workspace/{workspace}": { + "get": { + "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get session by workspace", + "parameters": [ + { + "type": "string", + "description": "Workspace directory path", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Include full session data with messages and user prompts", + "name": "full", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Basic session info when full=false", + "schema": { + "$ref": "#/definitions/internal_handlers.ActiveSessionInfo" + } + } + } + }, + "delete": { + "description": "Removes a session from the active sessions mapping", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Delete session", + "parameters": [ + { + "type": "string", + "description": "Workspace directory path", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.DeleteSessionResponse" + } + } + } + } + }, + "/v1/sessions/workspace/{workspace}/session/{sessionId}": { + "get": { + "description": "Returns complete session data for a specific session ID within a workspace", + "produces": [ + "application/json" + ], + "tags": [ + "sessions" + ], + "summary": "Get session by ID", + "parameters": [ + { + "type": "string", + "description": "Workspace directory path", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Session ID (UUID)", + "name": "sessionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.FullSessionData" + } + } + } + } + }, + "/v1/upload": { + "post": { + "description": "Upload a file to /tmp/uploads directory with automatic conflict resolution", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "Upload a file", + "parameters": [ + { + "type": "file", + "description": "File to upload", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.UploadResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.UploadResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_handlers.UploadResponse" + } + } + } + } + } + }, + "definitions": { + "github_com_vanpelt_catnip_internal_models.AgentCompletionRequest": { + "type": "object", + "properties": { + "agent_options": { + "description": "Agent-specific options", + "type": "object", + "additionalProperties": true + }, + "max_turns": { + "description": "Maximum number of turns in the conversation", + "type": "integer", + "example": 10 + }, + "model": { + "description": "Optional model override (agent-specific)", + "type": "string", + "example": "claude-3-5-sonnet-20241022" + }, + "prompt": { + "description": "The prompt/message to send to the agent", + "type": "string", + "example": "Help me debug this error" + }, + "resume": { + "description": "Whether to resume the most recent session for this working directory", + "type": "boolean", + "example": true + }, + "stream": { + "description": "Whether to stream the response", + "type": "boolean", + "example": true + }, + "suppress_events": { + "description": "Whether to suppress events for this automated operation", + "type": "boolean", + "example": true + }, + "system_prompt": { + "description": "Optional system prompt override", + "type": "string", + "example": "You are a helpful coding assistant" + }, + "working_directory": { + "description": "Working directory for the command", + "type": "string", + "example": "/workspace/my-project" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentCompletionResponse": { + "type": "object", + "properties": { + "agent_type": { + "description": "Agent that generated this response", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "error": { + "description": "Any error that occurred", + "type": "string" + }, + "is_chunk": { + "description": "Whether this is a streaming chunk or complete response", + "type": "boolean", + "example": false + }, + "is_last": { + "description": "Whether this is the last chunk in a stream", + "type": "boolean", + "example": true + }, + "response": { + "description": "The generated response text", + "type": "string", + "example": "I can help you debug that error..." + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentEvent": { + "type": "object", + "properties": { + "agent_type": { + "description": "Agent type that generated this event", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "data": { + "description": "Additional event-specific data", + "type": "object", + "additionalProperties": true + }, + "event_type": { + "description": "Type of the event (UserPromptSubmit, Stop, etc.)", + "type": "string", + "example": "UserPromptSubmit" + }, + "session_id": { + "description": "Session ID if available", + "type": "string", + "example": "abc123-def456-ghi789" + }, + "timestamp": { + "description": "Timestamp of the event", + "type": "string" + }, + "working_directory": { + "description": "Working directory where the event occurred", + "type": "string", + "example": "/workspace/my-project" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentFullSessionData": { + "type": "object", + "properties": { + "allSessions": { + "description": "All sessions available for this workspace", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionListEntry" + } + }, + "messageCount": { + "description": "Total message count in full data", + "type": "integer" + }, + "messages": { + "description": "Full conversation history (only when full=true)", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionMessage" + } + }, + "sessionInfo": { + "description": "Basic session information", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary" + } + ] + }, + "userPrompts": { + "description": "User prompts/history (agent-specific format)", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentHistoryEntry" + } + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentHistoryEntry": { + "type": "object", + "properties": { + "data": { + "description": "Agent-specific data", + "type": "object", + "additionalProperties": true + }, + "display": { + "type": "string" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSessionListEntry": { + "type": "object", + "properties": { + "endTime": { + "description": "When the session ended (if available)", + "type": "string", + "example": "2024-01-15T16:45:30Z" + }, + "isActive": { + "description": "Whether this session is currently active", + "type": "boolean", + "example": false + }, + "lastModified": { + "description": "When the session was last modified", + "type": "string", + "example": "2024-01-15T16:45:30Z" + }, + "sessionId": { + "description": "Unique session identifier", + "type": "string", + "example": "abc123-def456-ghi789" + }, + "startTime": { + "description": "When the session started (if available)", + "type": "string", + "example": "2024-01-15T14:30:00Z" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSessionMessage": { + "type": "object", + "properties": { + "agentType": { + "description": "Agent-specific message data", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "content": { + "description": "Raw agent-specific content", + "type": "object", + "additionalProperties": true + }, + "timestamp": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSessionSummary": { + "type": "object", + "properties": { + "agentType": { + "description": "Agent type", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ], + "example": "claude" + }, + "allSessions": { + "description": "List of all available sessions for this worktree", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionListEntry" + } + }, + "currentSessionId": { + "description": "ID of the currently active session", + "type": "string", + "example": "xyz789-ghi012" + }, + "header": { + "description": "Header/title of the session", + "type": "string", + "example": "Fix bug in user authentication" + }, + "isActive": { + "description": "Whether this session is currently active", + "type": "boolean", + "example": true + }, + "lastCost": { + "description": "Metrics (from completed sessions)\nCost in USD of the last completed session (agent-specific)", + "type": "number", + "example": 0.25 + }, + "lastDuration": { + "description": "Duration in seconds of the last session", + "type": "integer", + "example": 3600 + }, + "lastSessionId": { + "description": "ID of the most recent completed session", + "type": "string", + "example": "abc123-def456" + }, + "lastTotalInputTokens": { + "description": "Total input tokens used in the last session (agent-specific)", + "type": "integer", + "example": 15000 + }, + "lastTotalOutputTokens": { + "description": "Total output tokens generated in the last session (agent-specific)", + "type": "integer", + "example": 8500 + }, + "sessionEndTime": { + "description": "When the last session ended (if not active)", + "type": "string", + "example": "2024-01-15T16:45:30Z" + }, + "sessionStartTime": { + "description": "When the current session started", + "type": "string", + "example": "2024-01-15T14:30:00Z" + }, + "turnCount": { + "description": "Number of conversation turns in the session", + "type": "integer", + "example": 15 + }, + "worktreePath": { + "description": "Path to the worktree directory", + "type": "string", + "example": "/workspace/my-project" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSettings": { + "type": "object", + "properties": { + "agentSpecificSettings": { + "description": "Agent-specific settings", + "type": "object", + "additionalProperties": true + }, + "agentType": { + "description": "Agent type", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.AgentType" + } + ] + }, + "hasCompletedOnboarding": { + "description": "Whether user has completed onboarding", + "type": "boolean", + "example": true + }, + "isAuthenticated": { + "description": "Whether user is authenticated", + "type": "boolean", + "example": true + }, + "notificationsEnabled": { + "description": "Whether notifications are enabled", + "type": "boolean", + "example": true + }, + "numStartups": { + "description": "Number of times agent has been started", + "type": "integer", + "example": 15 + }, + "version": { + "description": "Version information", + "type": "string", + "example": "1.2.3" + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentSettingsUpdateRequest": { + "type": "object", + "properties": { + "agentSpecificSettings": { + "description": "Agent-specific settings updates", + "type": "object", + "additionalProperties": true + }, + "notificationsEnabled": { + "description": "Whether notifications should be enabled", + "type": "boolean", + "example": true + } + } + }, + "github_com_vanpelt_catnip_internal_models.AgentType": { + "type": "string", + "enum": [ + "claude", + "codex" + ], + "x-enum-varnames": [ + "AgentTypeClaude", + "AgentTypeCodex" + ] + }, "github_com_vanpelt_catnip_internal_models.ClaudeActivityState": { "type": "string", "enum": [ diff --git a/container/docs/swagger.yaml b/container/docs/swagger.yaml index 3122336c..207bdbaf 100644 --- a/container/docs/swagger.yaml +++ b/container/docs/swagger.yaml @@ -1,4 +1,276 @@ definitions: + github_com_vanpelt_catnip_internal_models.AgentCompletionRequest: + properties: + agent_options: + additionalProperties: true + description: Agent-specific options + type: object + max_turns: + description: Maximum number of turns in the conversation + example: 10 + type: integer + model: + description: Optional model override (agent-specific) + example: claude-3-5-sonnet-20241022 + type: string + prompt: + description: The prompt/message to send to the agent + example: Help me debug this error + type: string + resume: + description: Whether to resume the most recent session for this working directory + example: true + type: boolean + stream: + description: Whether to stream the response + example: true + type: boolean + suppress_events: + description: Whether to suppress events for this automated operation + example: true + type: boolean + system_prompt: + description: Optional system prompt override + example: You are a helpful coding assistant + type: string + working_directory: + description: Working directory for the command + example: /workspace/my-project + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentCompletionResponse: + properties: + agent_type: + allOf: + - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentType' + description: Agent that generated this response + error: + description: Any error that occurred + type: string + is_chunk: + description: Whether this is a streaming chunk or complete response + example: false + type: boolean + is_last: + description: Whether this is the last chunk in a stream + example: true + type: boolean + response: + description: The generated response text + example: I can help you debug that error... + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentEvent: + properties: + agent_type: + allOf: + - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentType' + description: Agent type that generated this event + data: + additionalProperties: true + description: Additional event-specific data + type: object + event_type: + description: Type of the event (UserPromptSubmit, Stop, etc.) + example: UserPromptSubmit + type: string + session_id: + description: Session ID if available + example: abc123-def456-ghi789 + type: string + timestamp: + description: Timestamp of the event + type: string + working_directory: + description: Working directory where the event occurred + example: /workspace/my-project + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentFullSessionData: + properties: + allSessions: + description: All sessions available for this workspace + items: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionListEntry' + type: array + messageCount: + description: Total message count in full data + type: integer + messages: + description: Full conversation history (only when full=true) + items: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionMessage' + type: array + sessionInfo: + allOf: + - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary' + description: Basic session information + userPrompts: + description: User prompts/history (agent-specific format) + items: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentHistoryEntry' + type: array + type: object + github_com_vanpelt_catnip_internal_models.AgentHistoryEntry: + properties: + data: + additionalProperties: true + description: Agent-specific data + type: object + display: + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentSessionListEntry: + properties: + endTime: + description: When the session ended (if available) + example: "2024-01-15T16:45:30Z" + type: string + isActive: + description: Whether this session is currently active + example: false + type: boolean + lastModified: + description: When the session was last modified + example: "2024-01-15T16:45:30Z" + type: string + sessionId: + description: Unique session identifier + example: abc123-def456-ghi789 + type: string + startTime: + description: When the session started (if available) + example: "2024-01-15T14:30:00Z" + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentSessionMessage: + properties: + agentType: + allOf: + - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentType' + description: Agent-specific message data + content: + additionalProperties: true + description: Raw agent-specific content + type: object + timestamp: + type: string + type: + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentSessionSummary: + properties: + agentType: + allOf: + - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentType' + description: Agent type + example: claude + allSessions: + description: List of all available sessions for this worktree + items: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionListEntry' + type: array + currentSessionId: + description: ID of the currently active session + example: xyz789-ghi012 + type: string + header: + description: Header/title of the session + example: Fix bug in user authentication + type: string + isActive: + description: Whether this session is currently active + example: true + type: boolean + lastCost: + description: |- + Metrics (from completed sessions) + Cost in USD of the last completed session (agent-specific) + example: 0.25 + type: number + lastDuration: + description: Duration in seconds of the last session + example: 3600 + type: integer + lastSessionId: + description: ID of the most recent completed session + example: abc123-def456 + type: string + lastTotalInputTokens: + description: Total input tokens used in the last session (agent-specific) + example: 15000 + type: integer + lastTotalOutputTokens: + description: Total output tokens generated in the last session (agent-specific) + example: 8500 + type: integer + sessionEndTime: + description: When the last session ended (if not active) + example: "2024-01-15T16:45:30Z" + type: string + sessionStartTime: + description: When the current session started + example: "2024-01-15T14:30:00Z" + type: string + turnCount: + description: Number of conversation turns in the session + example: 15 + type: integer + worktreePath: + description: Path to the worktree directory + example: /workspace/my-project + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentSettings: + properties: + agentSpecificSettings: + additionalProperties: true + description: Agent-specific settings + type: object + agentType: + allOf: + - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentType' + description: Agent type + hasCompletedOnboarding: + description: Whether user has completed onboarding + example: true + type: boolean + isAuthenticated: + description: Whether user is authenticated + example: true + type: boolean + notificationsEnabled: + description: Whether notifications are enabled + example: true + type: boolean + numStartups: + description: Number of times agent has been started + example: 15 + type: integer + version: + description: Version information + example: 1.2.3 + type: string + type: object + github_com_vanpelt_catnip_internal_models.AgentSettingsUpdateRequest: + properties: + agentSpecificSettings: + additionalProperties: true + description: Agent-specific settings updates + type: object + notificationsEnabled: + description: Whether notifications should be enabled + example: true + type: boolean + type: object + github_com_vanpelt_catnip_internal_models.AgentType: + enum: + - claude + - codex + type: string + x-enum-varnames: + - AgentTypeClaude + - AgentTypeCodex github_com_vanpelt_catnip_internal_models.ClaudeActivityState: enum: - inactive @@ -1051,6 +1323,226 @@ info: title: Catnip Container API version: "1.0" paths: + /v1/agents: + get: + description: Returns a list of all registered coding agents + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: string + type: array + summary: Get available agents + tags: + - agents + /v1/agents/events: + post: + consumes: + - application/json + description: Receives event notifications from coding agents for activity tracking + parameters: + - description: Agent event + in: body + name: request + required: true + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentEvent' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Handle agent events + tags: + - agents + /v1/agents/latest-message: + get: + description: Returns the most recent assistant message from any coding agent + session for a specific worktree + parameters: + - description: Worktree path + in: query + name: worktree_path + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Get worktree latest assistant message + tags: + - agents + /v1/agents/messages: + post: + consumes: + - application/json + description: Creates a completion using any registered coding agent, supporting + both streaming and non-streaming responses, with resume functionality + parameters: + - description: Create completion request + in: body + name: request + required: true + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentCompletionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentCompletionResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Create agent messages + tags: + - agents + /v1/agents/session: + get: + description: Returns coding agent session metadata for a specific worktree + parameters: + - description: Worktree path + in: query + name: worktree_path + required: true + type: string + - description: Agent type (defaults to Claude for backward compatibility) + in: query + name: agent_type + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary' + summary: Get worktree session summary + tags: + - agents + /v1/agents/session/{uuid}: + get: + description: Returns complete session data including all messages for a specific + session UUID + parameters: + - description: Session UUID + in: path + name: uuid + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentFullSessionData' + summary: Get session by UUID + tags: + - agents + /v1/agents/sessions: + get: + description: Returns coding agent session metadata for all worktrees with agent + data + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSessionSummary' + type: object + summary: Get all worktree session summaries + tags: + - agents + /v1/agents/settings: + get: + description: Returns coding agent configuration settings including authentication + status and other metadata + parameters: + - description: Agent type (defaults to Claude) + in: query + name: agent_type + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettings' + summary: Get agent settings + tags: + - agents + put: + consumes: + - application/json + description: Updates coding agent configuration settings + parameters: + - description: Agent type (defaults to Claude) + in: query + name: agent_type + type: string + - description: Settings update request + in: body + name: request + required: true + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettingsUpdateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.AgentSettings' + summary: Update agent settings + tags: + - agents + /v1/agents/todos: + get: + description: Returns the most recent TodoWrite structure from any coding agent + session for a specific worktree + parameters: + - description: Worktree path + in: query + name: worktree_path + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.Todo' + type: array + summary: Get worktree todos + tags: + - agents /v1/auth/github/reset: post: description: Clears any active authentication process diff --git a/container/internal/handlers/agent.go b/container/internal/handlers/agent.go new file mode 100644 index 00000000..914a8a4e --- /dev/null +++ b/container/internal/handlers/agent.go @@ -0,0 +1,567 @@ +package handlers + +import ( + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/vanpelt/catnip/internal/config" + "github.com/vanpelt/catnip/internal/logger" + "github.com/vanpelt/catnip/internal/models" + "github.com/vanpelt/catnip/internal/services" +) + +// AgentHandler handles multi-agent API endpoints +type AgentHandler struct { + agentManager *services.AgentManager + gitService *services.GitService + eventsHandler *EventsHandler +} + +// NewAgentHandler creates a new agent handler +func NewAgentHandler(agentManager *services.AgentManager, gitService *services.GitService) *AgentHandler { + return &AgentHandler{ + agentManager: agentManager, + gitService: gitService, + } +} + +// WithEvents adds events handler for broadcasting events +func (h *AgentHandler) WithEvents(eventsHandler *EventsHandler) *AgentHandler { + h.eventsHandler = eventsHandler + return h +} + +// GetAvailableAgents returns a list of available agent types +// @Summary Get available agents +// @Description Returns a list of all registered coding agents +// @Tags agents +// @Produce json +// @Success 200 {array} string +// @Router /v1/agents [get] +func (h *AgentHandler) GetAvailableAgents(c *fiber.Ctx) error { + agents := h.agentManager.GetAvailableAgents() + return c.JSON(agents) +} + +// GetWorktreeSessionSummary returns agent session information for a specific worktree +// @Summary Get worktree session summary +// @Description Returns coding agent session metadata for a specific worktree +// @Tags agents +// @Produce json +// @Param worktree_path query string true "Worktree path" +// @Param agent_type query string false "Agent type (defaults to Claude for backward compatibility)" +// @Success 200 {object} models.AgentSessionSummary +// @Router /v1/agents/session [get] +func (h *AgentHandler) GetWorktreeSessionSummary(c *fiber.Ctx) error { + worktreePath := c.Query("worktree_path") + if worktreePath == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "worktree_path query parameter is required", + }) + } + + summary, err := h.agentManager.GetWorktreeSessionSummary(worktreePath) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + if summary == nil { + return c.Status(404).JSON(fiber.Map{ + "error": "No agent session found for this worktree", + }) + } + + return c.JSON(summary) +} + +// GetAllWorktreeSessionSummaries returns agent session information for all worktrees +// @Summary Get all worktree session summaries +// @Description Returns coding agent session metadata for all worktrees with agent data +// @Tags agents +// @Produce json +// @Success 200 {object} map[string]models.AgentSessionSummary +// @Router /v1/agents/sessions [get] +func (h *AgentHandler) GetAllWorktreeSessionSummaries(c *fiber.Ctx) error { + summaries, err := h.agentManager.GetAllWorktreeSessionSummaries() + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(summaries) +} + +// GetSessionByUUID returns complete session data for a specific session UUID +// @Summary Get session by UUID +// @Description Returns complete session data including all messages for a specific session UUID +// @Tags agents +// @Produce json +// @Param uuid path string true "Session UUID" +// @Success 200 {object} models.AgentFullSessionData +// @Router /v1/agents/session/{uuid} [get] +func (h *AgentHandler) GetSessionByUUID(c *fiber.Ctx) error { + sessionUUID := c.Params("uuid") + if sessionUUID == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "session UUID is required", + }) + } + + sessionData, err := h.agentManager.GetSessionByUUID(sessionUUID) + if err != nil { + if strings.Contains(err.Error(), "session not found") { + return c.Status(404).JSON(fiber.Map{ + "error": "Session not found", + "uuid": sessionUUID, + }) + } + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to get session data", + "details": err.Error(), + }) + } + + return c.JSON(sessionData) +} + +// CreateCompletion handles requests to create completions using any agent +// @Summary Create agent messages +// @Description Creates a completion using any registered coding agent, supporting both streaming and non-streaming responses, with resume functionality +// @Tags agents +// @Accept json +// @Produce json +// @Param request body models.AgentCompletionRequest true "Create completion request" +// @Success 200 {object} models.AgentCompletionResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /v1/agents/messages [post] +func (h *AgentHandler) CreateCompletion(c *fiber.Ctx) error { + var req models.AgentCompletionRequest + + // Parse the request body + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + + // Validate required fields + if req.Prompt == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "Prompt is required", + }) + } + + // Create context for the request + ctx := c.Context() + + // Handle streaming response + if req.Stream { + // Set headers for streaming + c.Set("Content-Type", "application/json") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + + // Use the streaming method + return h.agentManager.CreateStreamingCompletion(ctx, &req, c.Response().BodyWriter()) + } + + // Handle non-streaming response + logger.Infof("🔍 Creating agent completion for prompt: %.100s...", req.Prompt) + resp, err := h.agentManager.CreateCompletion(ctx, &req) + if err != nil { + logger.Errorf("❌ Agent completion failed: %v", err) + // Handle specific error types + if strings.Contains(err.Error(), "prompt is required") { + return c.Status(400).JSON(fiber.Map{ + "error": "Prompt is required", + }) + } + + if strings.Contains(err.Error(), "command failed") { + return c.Status(500).JSON(fiber.Map{ + "error": "Agent CLI execution failed", + "details": err.Error(), + }) + } + + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + logger.Infof("✅ Agent completion successful. Response length: %d chars", len(resp.Response)) + logger.Debugf("📝 Agent response content: %s", resp.Response) + return c.JSON(resp) +} + +// GetWorktreeTodos returns the most recent Todo structure from the session history for a specific worktree +// @Summary Get worktree todos +// @Description Returns the most recent TodoWrite structure from any coding agent session for a specific worktree +// @Tags agents +// @Produce json +// @Param worktree_path query string true "Worktree path" +// @Success 200 {array} models.Todo +// @Router /v1/agents/todos [get] +func (h *AgentHandler) GetWorktreeTodos(c *fiber.Ctx) error { + worktreePath := c.Query("worktree_path") + if worktreePath == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "worktree_path query parameter is required", + }) + } + + todos, err := h.agentManager.GetLatestTodos(worktreePath) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + // Return empty array if no todos found instead of null + if todos == nil { + todos = []models.Todo{} + } + + return c.JSON(todos) +} + +// GetWorktreeLatestAssistantMessage returns the most recent assistant message from the session history for a specific worktree +// @Summary Get worktree latest assistant message +// @Description Returns the most recent assistant message from any coding agent session for a specific worktree +// @Tags agents +// @Produce json +// @Param worktree_path query string true "Worktree path" +// @Success 200 {object} map[string]interface{} +// @Router /v1/agents/latest-message [get] +func (h *AgentHandler) GetWorktreeLatestAssistantMessage(c *fiber.Ctx) error { + worktreePath := c.Query("worktree_path") + if worktreePath == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "worktree_path query parameter is required", + }) + } + + message, isError, err := h.agentManager.GetLatestAssistantMessageOrError(worktreePath) + if err != nil { + if strings.Contains(err.Error(), "project directory not found") { + return c.Status(404).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "content": message, + "isError": isError, + }) +} + +// GetAgentSettings returns agent configuration settings +// @Summary Get agent settings +// @Description Returns coding agent configuration settings including authentication status and other metadata +// @Tags agents +// @Produce json +// @Param agent_type query string false "Agent type (defaults to Claude)" +// @Success 200 {object} models.AgentSettings +// @Router /v1/agents/settings [get] +func (h *AgentHandler) GetAgentSettings(c *fiber.Ctx) error { + agentType := models.AgentType(c.Query("agent_type")) + if agentType == "" { + agentType = models.AgentTypeClaude // Default to Claude for backward compatibility + } + + settings, err := h.agentManager.GetSettings(agentType) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(settings) +} + +// UpdateAgentSettings updates agent configuration settings +// @Summary Update agent settings +// @Description Updates coding agent configuration settings +// @Tags agents +// @Accept json +// @Produce json +// @Param agent_type query string false "Agent type (defaults to Claude)" +// @Param request body models.AgentSettingsUpdateRequest true "Settings update request" +// @Success 200 {object} models.AgentSettings +// @Router /v1/agents/settings [put] +func (h *AgentHandler) UpdateAgentSettings(c *fiber.Ctx) error { + agentType := models.AgentType(c.Query("agent_type")) + if agentType == "" { + agentType = models.AgentTypeClaude // Default to Claude for backward compatibility + } + + var req models.AgentSettingsUpdateRequest + + // Parse the request body + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + + // Validate that at least one field is provided + if req.NotificationsEnabled == nil && len(req.AgentSpecificSettings) == 0 { + return c.Status(400).JSON(fiber.Map{ + "error": "At least one setting must be provided", + }) + } + + // Update settings + settings, err := h.agentManager.UpdateSettings(agentType, &req) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(settings) +} + +// HandleAgentEvent handles agent event notifications +// @Summary Handle agent events +// @Description Receives event notifications from coding agents for activity tracking +// @Tags agents +// @Accept json +// @Produce json +// @Param request body models.AgentEvent true "Agent event" +// @Success 200 {object} map[string]string +// @Router /v1/agents/events [post] +func (h *AgentHandler) HandleAgentEvent(c *fiber.Ctx) error { + var req models.AgentEvent + + // Log the raw request body for debugging + bodyBytes := c.Body() + logger.Debugf("🔔 Agent event received - Raw body: %s", string(bodyBytes)) + + // Parse the request body + if err := c.BodyParser(&req); err != nil { + logger.Debugf("❌ Event parsing error: %v", err) + return c.Status(400).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + + // Log the parsed event + logger.Debugf("🔔 Parsed agent event - Type: %s, WorkDir: %s, Agent: %s", req.EventType, req.WorkingDirectory, req.AgentType) + + // Validate required fields + if req.EventType == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "event_type is required", + }) + } + + if req.WorkingDirectory == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "working_directory is required", + }) + } + + if req.AgentType == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "agent_type is required", + }) + } + + // Set timestamp if not provided + if req.Timestamp.IsZero() { + req.Timestamp = time.Now() + } + + // Handle the event + err := h.agentManager.HandleEvent(&req) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": "Failed to handle agent event", + "details": err.Error(), + }) + } + + // Trigger immediate activity state sync for activity-related events + if req.EventType == "UserPromptSubmit" || req.EventType == "PostToolUse" || req.EventType == "Stop" { + logger.Debugf("🔄 Triggering immediate activity state sync for %s", req.EventType) + if stateManager := h.gitService.GetStateManager(); stateManager != nil { + stateManager.TriggerClaudeActivitySync() + } + } + + // Trigger immediate commit sync for Stop events to auto-commit dirty changes + if req.EventType == "Stop" { + logger.Debugf("🔄 Triggering immediate commit sync for Stop event in %s", req.WorkingDirectory) + if commitSyncService := h.gitService.GetCommitSyncService(); commitSyncService != nil { + commitSyncService.PerformManualSync() + } + } + + // Emit agent message on PostToolUse events + if h.eventsHandler != nil && req.EventType == "PostToolUse" { + h.handlePostToolUseEvent(&req) + } + + // Handle special events that should broadcast to frontend + if h.eventsHandler != nil && req.EventType == "Stop" { + h.handleStopEvent(&req) + } + + return c.JSON(fiber.Map{ + "status": "success", + "message": "Agent event processed successfully", + }) +} + +// handlePostToolUseEvent handles PostToolUse events +func (h *AgentHandler) handlePostToolUseEvent(event *models.AgentEvent) { + // Find the workspace directory - handle subdirectories by checking workspace prefix + workspaceDir := event.WorkingDirectory + worktrees := h.gitService.ListWorktrees() + + // Check if working directory is a subdirectory of any workspace + var matchingWorktree *models.Worktree + for _, wt := range worktrees { + if strings.HasPrefix(event.WorkingDirectory, wt.Path) { + // Use the longest matching path (most specific workspace) + if matchingWorktree == nil || len(wt.Path) > len(matchingWorktree.Path) { + matchingWorktree = wt + workspaceDir = wt.Path + } + } + } + + // Get the latest assistant message if we found a matching worktree + if matchingWorktree != nil { + if latestMessage, err := h.agentManager.GetLatestAssistantMessage(workspaceDir); err == nil && latestMessage != "" { + logger.Debugf("📨 Emitting agent message for worktree %s", matchingWorktree.ID) + h.eventsHandler.EmitClaudeMessage(workspaceDir, matchingWorktree.ID, latestMessage, "assistant") + } else if err != nil { + logger.Debugf("📨 Failed to get latest assistant message: %v", err) + } + } +} + +// handleStopEvent handles Stop events +func (h *AgentHandler) handleStopEvent(event *models.AgentEvent) { + // Find the workspace directory - handle subdirectories by checking workspace prefix + workspaceDir := event.WorkingDirectory + worktrees := h.gitService.ListWorktrees() + + // Check if working directory is a subdirectory of any workspace + var matchingWorktree *models.Worktree + for _, wt := range worktrees { + if strings.HasPrefix(event.WorkingDirectory, wt.Path) { + // Use the longest matching path (most specific workspace) + if matchingWorktree == nil || len(wt.Path) > len(matchingWorktree.Path) { + matchingWorktree = wt + workspaceDir = wt.Path + } + } + } + + // Check if events are suppressed for this workspace (automated operation) + // For backward compatibility, check Claude service if it's a Claude event + var eventsSuppressed bool + if event.AgentType == models.AgentTypeClaude { + // Get Claude agent to check suppression + if claudeAgent, err := h.agentManager.GetAgent(models.AgentTypeClaude); err == nil { + if claudeAgentImpl, ok := claudeAgent.(*services.ClaudeAgent); ok { + eventsSuppressed = claudeAgentImpl.IsSuppressingEvents(workspaceDir) + } + } + } + + if eventsSuppressed { + logger.Debugf("🔔 Skipping stop event - automated operation in progress for %s", workspaceDir) + return + } + + // Send stop event for any matching workspace + if matchingWorktree != nil { + logger.Debugf("🔔 Emitting session stopped event for %s (branch: %s)", workspaceDir, matchingWorktree.Branch) + + // Get session information for this worktree + todos, _ := h.agentManager.GetLatestTodos(workspaceDir) + + // Create title: branch name truncated to 15 chars + " stopped" + branchName := matchingWorktree.Branch + if len(branchName) > 15 { + branchName = branchName[:15] + } + title := branchName + " stopped" + + // Create description: last todo truncated to 50 chars or generic message + var description string + if len(todos) > 0 { + // Find the last incomplete todo + var lastTodo string + for i := len(todos) - 1; i >= 0; i-- { + if todos[i].Status != "completed" { + lastTodo = todos[i].Content + break + } + } + // If all todos are completed, use the last one + if lastTodo == "" && len(todos) > 0 { + lastTodo = todos[len(todos)-1].Content + } + + if lastTodo != "" { + if len(lastTodo) > 50 { + lastTodo = lastTodo[:50] + "..." + } + description = lastTodo + } else { + description = "Session has completed" + } + } else { + description = "Session has completed" + } + + // Emit the session stopped event with improved content + h.eventsHandler.EmitSessionStopped( + workspaceDir, + nil, // worktreeID not needed + &title, + &matchingWorktree.Branch, // Keep full branch name for context + &description, + ) + + // Also emit a notification event directly via SSE if notifications are enabled + if settings, err := h.agentManager.GetSettings(event.AgentType); err == nil && settings.NotificationsEnabled { + logger.Debugf("🔔 Emitting notification event: %s", title) + + // Generate workspace URL - remove workspace prefix if present + workspacePath := strings.TrimPrefix(workspaceDir, config.Runtime.WorkspaceDir) + workspaceURL := fmt.Sprintf("http://localhost:6369/workspace%s", workspacePath) + + h.eventsHandler.broadcastEvent(AppEvent{ + Type: NotificationEvent, + Payload: NotificationPayload{ + Title: title, + Body: description, + Subtitle: "", // Leave empty for consistency with existing notification structure + URL: workspaceURL, + }, + }) + } else if err != nil { + logger.Debugf("🔔 Failed to get agent settings for notification check: %v", err) + } else { + logger.Debugf("🔔 Notifications disabled, skipping notification event") + } + } else { + logger.Debugf("🔔 Skipping stop event - no matching workspace found for %s", event.WorkingDirectory) + } +} diff --git a/container/internal/models/agent.go b/container/internal/models/agent.go new file mode 100644 index 00000000..af3d5958 --- /dev/null +++ b/container/internal/models/agent.go @@ -0,0 +1,230 @@ +package models + +import ( + "context" + "io" + "time" +) + +// AgentType represents the type of coding agent +type AgentType string + +// Supported agent types +const ( + AgentTypeClaude AgentType = "claude" + AgentTypeCodex AgentType = "codex" +) + +// Agent represents a coding agent (Claude, Codex, etc.) +type Agent interface { + // Core agent operations + GetType() AgentType + GetName() string + + // Session management + GetWorktreeSessionSummary(worktreePath string) (*AgentSessionSummary, error) + GetAllWorktreeSessionSummaries() (map[string]*AgentSessionSummary, error) + GetFullSessionData(worktreePath string, includeFullData bool) (*AgentFullSessionData, error) + GetSessionByUUID(sessionUUID string) (*AgentFullSessionData, error) + + // Content retrieval + GetLatestTodos(worktreePath string) ([]Todo, error) + GetLatestAssistantMessage(worktreePath string) (string, error) + GetLatestAssistantMessageOrError(worktreePath string) (content string, isError bool, err error) + + // Completion creation + CreateCompletion(ctx context.Context, req *AgentCompletionRequest) (*AgentCompletionResponse, error) + CreateStreamingCompletion(ctx context.Context, req *AgentCompletionRequest, responseWriter io.Writer) error + + // Settings management + GetSettings() (*AgentSettings, error) + UpdateSettings(req *AgentSettingsUpdateRequest) (*AgentSettings, error) + + // Activity tracking + UpdateActivity(worktreePath string) + GetLastActivity(worktreePath string) time.Time + IsActiveSession(worktreePath string, within time.Duration) bool + + // Event handling + HandleEvent(event *AgentEvent) error + + // Lifecycle management + Start() error + Stop() + CleanupWorktreeFiles(worktreePath string) error +} + +// AgentMonitor handles file watching and event extraction for an agent +type AgentMonitor interface { + // Monitoring lifecycle + Start() error + Stop() + + // Event handling + OnWorktreeCreated(worktreeID, worktreePath string) + OnWorktreeDeleted(worktreeID, worktreePath string) + + // Activity tracking + GetLastActivityTime(worktreePath string) time.Time + GetTodos(worktreePath string) ([]Todo, error) + GetActivityState(worktreePath string) ClaudeActivityState + + // Manual operations + TriggerBranchRename(workDir string, customBranchName string) error + RefreshTodoMonitoring() +} + +// AgentSessionSummary represents session information for any agent +type AgentSessionSummary struct { + // Path to the worktree directory + WorktreePath string `json:"worktreePath" example:"/workspace/my-project"` + // Agent type + AgentType AgentType `json:"agentType" example:"claude"` + // When the current session started + SessionStartTime *time.Time `json:"sessionStartTime" example:"2024-01-15T14:30:00Z"` + // When the last session ended (if not active) + SessionEndTime *time.Time `json:"sessionEndTime" example:"2024-01-15T16:45:30Z"` + // Number of conversation turns in the session + TurnCount int `json:"turnCount" example:"15"` + // Whether this session is currently active + IsActive bool `json:"isActive" example:"true"` + // ID of the most recent completed session + LastSessionId *string `json:"lastSessionId" example:"abc123-def456"` + // ID of the currently active session + CurrentSessionId *string `json:"currentSessionId,omitempty" example:"xyz789-ghi012"` + // List of all available sessions for this worktree + AllSessions []AgentSessionListEntry `json:"allSessions,omitempty"` + // Header/title of the session + Header *string `json:"header,omitempty" example:"Fix bug in user authentication"` + + // Metrics (from completed sessions) + // Cost in USD of the last completed session (agent-specific) + LastCost *float64 `json:"lastCost,omitempty" example:"0.25"` + // Duration in seconds of the last session + LastDuration *int `json:"lastDuration,omitempty" example:"3600"` + // Total input tokens used in the last session (agent-specific) + LastTotalInputTokens *int `json:"lastTotalInputTokens,omitempty" example:"15000"` + // Total output tokens generated in the last session (agent-specific) + LastTotalOutputTokens *int `json:"lastTotalOutputTokens,omitempty" example:"8500"` +} + +// AgentSessionListEntry represents a single session in a list with basic metadata +type AgentSessionListEntry struct { + // Unique session identifier + SessionId string `json:"sessionId" example:"abc123-def456-ghi789"` + // When the session was last modified + LastModified time.Time `json:"lastModified" example:"2024-01-15T16:45:30Z"` + // When the session started (if available) + StartTime *time.Time `json:"startTime,omitempty" example:"2024-01-15T14:30:00Z"` + // When the session ended (if available) + EndTime *time.Time `json:"endTime,omitempty" example:"2024-01-15T16:45:30Z"` + // Whether this session is currently active + IsActive bool `json:"isActive" example:"false"` +} + +// AgentFullSessionData represents complete session data including all messages +type AgentFullSessionData struct { + // Basic session information + SessionInfo *AgentSessionSummary `json:"sessionInfo"` + // All sessions available for this workspace + AllSessions []AgentSessionListEntry `json:"allSessions"` + // Full conversation history (only when full=true) + Messages []AgentSessionMessage `json:"messages,omitempty"` + // User prompts/history (agent-specific format) + UserPrompts []AgentHistoryEntry `json:"userPrompts,omitempty"` + // Total message count in full data + MessageCount int `json:"messageCount,omitempty"` +} + +// AgentSessionMessage represents a message in any agent's session format +type AgentSessionMessage struct { + // Agent-specific message data + AgentType AgentType `json:"agentType"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Content map[string]interface{} `json:"content"` // Raw agent-specific content +} + +// AgentHistoryEntry represents a history entry for any agent +type AgentHistoryEntry struct { + Display string `json:"display"` + Data map[string]interface{} `json:"data"` // Agent-specific data +} + +// AgentCompletionRequest represents a request to create a completion using any agent +type AgentCompletionRequest struct { + // The prompt/message to send to the agent + Prompt string `json:"prompt" example:"Help me debug this error"` + // Whether to stream the response + Stream bool `json:"stream,omitempty" example:"true"` + // Optional system prompt override + SystemPrompt string `json:"system_prompt,omitempty" example:"You are a helpful coding assistant"` + // Optional model override (agent-specific) + Model string `json:"model,omitempty" example:"claude-3-5-sonnet-20241022"` + // Maximum number of turns in the conversation + MaxTurns int `json:"max_turns,omitempty" example:"10"` + // Working directory for the command + WorkingDirectory string `json:"working_directory,omitempty" example:"/workspace/my-project"` + // Whether to resume the most recent session for this working directory + Resume bool `json:"resume,omitempty" example:"true"` + // Whether to suppress events for this automated operation + SuppressEvents bool `json:"suppress_events,omitempty" example:"true"` + // Agent-specific options + AgentOptions map[string]interface{} `json:"agent_options,omitempty"` +} + +// AgentCompletionResponse represents a response from any agent +type AgentCompletionResponse struct { + // The generated response text + Response string `json:"response" example:"I can help you debug that error..."` + // Whether this is a streaming chunk or complete response + IsChunk bool `json:"is_chunk,omitempty" example:"false"` + // Whether this is the last chunk in a stream + IsLast bool `json:"is_last,omitempty" example:"true"` + // Any error that occurred + Error string `json:"error,omitempty"` + // Agent that generated this response + AgentType AgentType `json:"agent_type"` +} + +// AgentSettings represents configuration settings for any agent +type AgentSettings struct { + // Agent type + AgentType AgentType `json:"agentType"` + // Whether user is authenticated + IsAuthenticated bool `json:"isAuthenticated" example:"true"` + // Version information + Version string `json:"version,omitempty" example:"1.2.3"` + // Whether user has completed onboarding + HasCompletedOnboarding bool `json:"hasCompletedOnboarding" example:"true"` + // Number of times agent has been started + NumStartups int `json:"numStartups" example:"15"` + // Whether notifications are enabled + NotificationsEnabled bool `json:"notificationsEnabled" example:"true"` + // Agent-specific settings + AgentSpecificSettings map[string]interface{} `json:"agentSpecificSettings,omitempty"` +} + +// AgentSettingsUpdateRequest represents a request to update agent settings +type AgentSettingsUpdateRequest struct { + // Whether notifications should be enabled + NotificationsEnabled *bool `json:"notificationsEnabled,omitempty" example:"true"` + // Agent-specific settings updates + AgentSpecificSettings map[string]interface{} `json:"agentSpecificSettings,omitempty"` +} + +// AgentEvent represents an event from any agent +type AgentEvent struct { + // Type of the event (UserPromptSubmit, Stop, etc.) + EventType string `json:"event_type" example:"UserPromptSubmit"` + // Working directory where the event occurred + WorkingDirectory string `json:"working_directory" example:"/workspace/my-project"` + // Session ID if available + SessionID string `json:"session_id,omitempty" example:"abc123-def456-ghi789"` + // Agent type that generated this event + AgentType AgentType `json:"agent_type"` + // Additional event-specific data + Data map[string]interface{} `json:"data,omitempty"` + // Timestamp of the event + Timestamp time.Time `json:"timestamp"` +} diff --git a/container/internal/services/agent_manager.go b/container/internal/services/agent_manager.go new file mode 100644 index 00000000..fef3b09d --- /dev/null +++ b/container/internal/services/agent_manager.go @@ -0,0 +1,378 @@ +package services + +import ( + "context" + "fmt" + "io" + "sync" + "time" + + "github.com/vanpelt/catnip/internal/models" +) + +// AgentManager manages multiple coding agents and provides a unified interface +type AgentManager struct { + agents map[models.AgentType]models.Agent + monitors map[models.AgentType]models.AgentMonitor + defaultAgent models.AgentType + agentMutex sync.RWMutex + stateManager *WorktreeStateManager + gitService *GitService +} + +// NewAgentManager creates a new agent manager +func NewAgentManager(stateManager *WorktreeStateManager, gitService *GitService) *AgentManager { + return &AgentManager{ + agents: make(map[models.AgentType]models.Agent), + monitors: make(map[models.AgentType]models.AgentMonitor), + defaultAgent: models.AgentTypeClaude, // Default to Claude for backward compatibility + stateManager: stateManager, + gitService: gitService, + } +} + +// RegisterAgent registers an agent with the manager +func (am *AgentManager) RegisterAgent(agent models.Agent, monitor models.AgentMonitor) error { + am.agentMutex.Lock() + defer am.agentMutex.Unlock() + + agentType := agent.GetType() + am.agents[agentType] = agent + if monitor != nil { + am.monitors[agentType] = monitor + } + + return nil +} + +// SetDefaultAgent sets the default agent type +func (am *AgentManager) SetDefaultAgent(agentType models.AgentType) error { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + if _, exists := am.agents[agentType]; !exists { + return fmt.Errorf("agent type %s not registered", agentType) + } + + am.defaultAgent = agentType + return nil +} + +// GetAgent gets an agent by type, falling back to default +func (am *AgentManager) GetAgent(agentType models.AgentType) (models.Agent, error) { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + if agentType == "" { + agentType = am.defaultAgent + } + + agent, exists := am.agents[agentType] + if !exists { + return nil, fmt.Errorf("agent type %s not registered", agentType) + } + + return agent, nil +} + +// GetMonitor gets a monitor by agent type +func (am *AgentManager) GetMonitor(agentType models.AgentType) (models.AgentMonitor, error) { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + if agentType == "" { + agentType = am.defaultAgent + } + + monitor, exists := am.monitors[agentType] + if !exists { + return nil, fmt.Errorf("monitor for agent type %s not registered", agentType) + } + + return monitor, nil +} + +// GetWorktreeAgent determines which agent to use for a given worktree +// This could be enhanced with per-worktree agent configuration +func (am *AgentManager) GetWorktreeAgent(worktreePath string) (models.Agent, error) { + // For now, use default agent + // TODO: Add per-worktree agent configuration + return am.GetAgent(am.defaultAgent) +} + +// GetWorktreeMonitor gets the monitor for a given worktree +func (am *AgentManager) GetWorktreeMonitor(worktreePath string) (models.AgentMonitor, error) { + // For now, use default agent's monitor + // TODO: Add per-worktree agent configuration + return am.GetMonitor(am.defaultAgent) +} + +// Start starts all registered agents and monitors +func (am *AgentManager) Start() error { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + // Start all agents + for agentType, agent := range am.agents { + if err := agent.Start(); err != nil { + return fmt.Errorf("failed to start agent %s: %w", agentType, err) + } + } + + // Start all monitors + for agentType, monitor := range am.monitors { + if err := monitor.Start(); err != nil { + return fmt.Errorf("failed to start monitor for agent %s: %w", agentType, err) + } + } + + return nil +} + +// Stop stops all agents and monitors +func (am *AgentManager) Stop() { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + // Stop all monitors first + for _, monitor := range am.monitors { + monitor.Stop() + } + + // Stop all agents + for _, agent := range am.agents { + agent.Stop() + } +} + +// Unified API methods that delegate to the appropriate agent + +// GetWorktreeSessionSummary gets session summary for a worktree using the appropriate agent +func (am *AgentManager) GetWorktreeSessionSummary(worktreePath string) (*models.AgentSessionSummary, error) { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return nil, err + } + return agent.GetWorktreeSessionSummary(worktreePath) +} + +// GetAllWorktreeSessionSummaries gets session summaries for all worktrees +func (am *AgentManager) GetAllWorktreeSessionSummaries() (map[string]*models.AgentSessionSummary, error) { + // For now, use default agent to get all summaries + // TODO: Could aggregate from all agents + agent, err := am.GetAgent(am.defaultAgent) + if err != nil { + return nil, err + } + return agent.GetAllWorktreeSessionSummaries() +} + +// GetFullSessionData gets complete session data for a worktree +func (am *AgentManager) GetFullSessionData(worktreePath string, includeFullData bool) (*models.AgentFullSessionData, error) { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return nil, err + } + return agent.GetFullSessionData(worktreePath, includeFullData) +} + +// GetSessionByUUID gets session data by UUID (searches all agents) +func (am *AgentManager) GetSessionByUUID(sessionUUID string) (*models.AgentFullSessionData, error) { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + // Try each agent until we find the session + for _, agent := range am.agents { + data, err := agent.GetSessionByUUID(sessionUUID) + if err == nil && data != nil { + return data, nil + } + } + + return nil, fmt.Errorf("session not found: %s", sessionUUID) +} + +// GetLatestTodos gets latest todos for a worktree +func (am *AgentManager) GetLatestTodos(worktreePath string) ([]models.Todo, error) { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return nil, err + } + return agent.GetLatestTodos(worktreePath) +} + +// GetLatestAssistantMessage gets latest assistant message for a worktree +func (am *AgentManager) GetLatestAssistantMessage(worktreePath string) (string, error) { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return "", err + } + return agent.GetLatestAssistantMessage(worktreePath) +} + +// GetLatestAssistantMessageOrError gets latest assistant message or error for a worktree +func (am *AgentManager) GetLatestAssistantMessageOrError(worktreePath string) (content string, isError bool, err error) { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return "", false, err + } + return agent.GetLatestAssistantMessageOrError(worktreePath) +} + +// CreateCompletion creates a completion using the appropriate agent +func (am *AgentManager) CreateCompletion(ctx context.Context, req *models.AgentCompletionRequest) (*models.AgentCompletionResponse, error) { + var agentType models.AgentType + if req.AgentOptions != nil { + if at, ok := req.AgentOptions["agent_type"].(string); ok { + agentType = models.AgentType(at) + } + } + + agent, err := am.GetAgent(agentType) + if err != nil { + return nil, err + } + + return agent.CreateCompletion(ctx, req) +} + +// CreateStreamingCompletion creates a streaming completion using the appropriate agent +func (am *AgentManager) CreateStreamingCompletion(ctx context.Context, req *models.AgentCompletionRequest, responseWriter io.Writer) error { + var agentType models.AgentType + if req.AgentOptions != nil { + if at, ok := req.AgentOptions["agent_type"].(string); ok { + agentType = models.AgentType(at) + } + } + + agent, err := am.GetAgent(agentType) + if err != nil { + return err + } + + return agent.CreateStreamingCompletion(ctx, req, responseWriter) +} + +// GetSettings gets settings for the default agent (or specific agent) +func (am *AgentManager) GetSettings(agentType models.AgentType) (*models.AgentSettings, error) { + agent, err := am.GetAgent(agentType) + if err != nil { + return nil, err + } + return agent.GetSettings() +} + +// UpdateSettings updates settings for the default agent (or specific agent) +func (am *AgentManager) UpdateSettings(agentType models.AgentType, req *models.AgentSettingsUpdateRequest) (*models.AgentSettings, error) { + agent, err := am.GetAgent(agentType) + if err != nil { + return nil, err + } + return agent.UpdateSettings(req) +} + +// HandleEvent handles an event and routes it to the appropriate agent +func (am *AgentManager) HandleEvent(event *models.AgentEvent) error { + agent, err := am.GetAgent(event.AgentType) + if err != nil { + return err + } + return agent.HandleEvent(event) +} + +// UpdateActivity updates activity for a worktree using the appropriate agent +func (am *AgentManager) UpdateActivity(worktreePath string) { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return // Silently ignore errors for activity updates + } + agent.UpdateActivity(worktreePath) +} + +// GetLastActivity gets last activity time for a worktree +func (am *AgentManager) GetLastActivity(worktreePath string) time.Time { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return time.Time{} // Return zero time on error + } + return agent.GetLastActivity(worktreePath) +} + +// IsActiveSession checks if a session is active within the specified duration +func (am *AgentManager) IsActiveSession(worktreePath string, within time.Duration) bool { + agent, err := am.GetWorktreeAgent(worktreePath) + if err != nil { + return false + } + return agent.IsActiveSession(worktreePath, within) +} + +// CleanupWorktreeFiles cleans up agent files for a worktree +func (am *AgentManager) CleanupWorktreeFiles(worktreePath string) error { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + // Clean up files for all agents + var errs []error + for _, agent := range am.agents { + if err := agent.CleanupWorktreeFiles(worktreePath); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return fmt.Errorf("cleanup errors: %v", errs) + } + + return nil +} + +// OnWorktreeCreated notifies all monitors about a new worktree +func (am *AgentManager) OnWorktreeCreated(worktreeID, worktreePath string) { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + for _, monitor := range am.monitors { + monitor.OnWorktreeCreated(worktreeID, worktreePath) + } +} + +// OnWorktreeDeleted notifies all monitors about a deleted worktree +func (am *AgentManager) OnWorktreeDeleted(worktreeID, worktreePath string) { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + for _, monitor := range am.monitors { + monitor.OnWorktreeDeleted(worktreeID, worktreePath) + } +} + +// GetActivityState gets activity state for a worktree +func (am *AgentManager) GetActivityState(worktreePath string) models.ClaudeActivityState { + monitor, err := am.GetWorktreeMonitor(worktreePath) + if err != nil { + return models.ClaudeInactive + } + return monitor.GetActivityState(worktreePath) +} + +// TriggerBranchRename triggers branch renaming for a worktree +func (am *AgentManager) TriggerBranchRename(workDir string, customBranchName string) error { + monitor, err := am.GetWorktreeMonitor(workDir) + if err != nil { + return err + } + return monitor.TriggerBranchRename(workDir, customBranchName) +} + +// GetAvailableAgents returns a list of all registered agent types +func (am *AgentManager) GetAvailableAgents() []models.AgentType { + am.agentMutex.RLock() + defer am.agentMutex.RUnlock() + + var agents []models.AgentType + for agentType := range am.agents { + agents = append(agents, agentType) + } + return agents +} diff --git a/container/internal/services/agent_service_init.go b/container/internal/services/agent_service_init.go new file mode 100644 index 00000000..5444d791 --- /dev/null +++ b/container/internal/services/agent_service_init.go @@ -0,0 +1,150 @@ +package services + +import ( + "fmt" + + "github.com/vanpelt/catnip/internal/logger" + "github.com/vanpelt/catnip/internal/models" +) + +// InitializeAgentServices initializes all agent services and returns the agent manager +func InitializeAgentServices(stateManager *WorktreeStateManager, gitService *GitService) (*AgentManager, error) { + logger.Info("🤖 Initializing multi-agent services") + + // Create agent manager + agentManager := NewAgentManager(stateManager, gitService) + + // Initialize Claude agent (existing functionality) + if err := initializeClaudeAgent(agentManager, stateManager, gitService); err != nil { + return nil, fmt.Errorf("failed to initialize Claude agent: %w", err) + } + + // Initialize Codex agent (new functionality) + if err := initializeCodexAgent(agentManager, stateManager, gitService); err != nil { + logger.Warnf("⚠️ Failed to initialize Codex agent: %v (continuing with Claude only)", err) + // Don't fail completely if Codex isn't available + } + + // Start the agent manager + if err := agentManager.Start(); err != nil { + return nil, fmt.Errorf("failed to start agent manager: %w", err) + } + + logger.Infof("✅ Agent services initialized with %d agents", len(agentManager.GetAvailableAgents())) + return agentManager, nil +} + +// initializeClaudeAgent initializes the Claude agent and monitor +func initializeClaudeAgent(agentManager *AgentManager, stateManager *WorktreeStateManager, gitService *GitService) error { + logger.Info("🤖 Initializing Claude agent") + + // Create Claude service (existing) + claudeService := NewClaudeService() + + // Create Claude monitor service (existing) + claudeMonitor := NewClaudeMonitorService(gitService, NewSessionService(), claudeService, stateManager) + + // Create Claude agent adapter + claudeAgent := NewClaudeAgent(claudeService) + + // Create Claude monitor adapter + claudeMonitorAdapter := NewClaudeMonitorAdapter(claudeMonitor) + + // Register with agent manager + if err := agentManager.RegisterAgent(claudeAgent, claudeMonitorAdapter); err != nil { + return fmt.Errorf("failed to register Claude agent: %w", err) + } + + logger.Info("✅ Claude agent initialized") + return nil +} + +// initializeCodexAgent initializes the Codex agent and monitor +func initializeCodexAgent(agentManager *AgentManager, stateManager *WorktreeStateManager, gitService *GitService) error { + logger.Info("🤖 Initializing Codex agent") + + // Check if Codex CLI is available + if !isCodexAvailable() { + return fmt.Errorf("codex CLI not found in PATH") + } + + // Create Codex agent + codexAgent := NewCodexAgent() + + // Create Codex monitor + codexMonitor := NewCodexMonitor(codexAgent, stateManager, gitService) + + // Set up event forwarding from monitor to agent + codexMonitor.AddEventHandler(func(event *models.AgentEvent) error { + return codexAgent.HandleEvent(event) + }) + + // Register with agent manager + if err := agentManager.RegisterAgent(codexAgent, codexMonitor); err != nil { + return fmt.Errorf("failed to register Codex agent: %w", err) + } + + logger.Info("✅ Codex agent initialized") + return nil +} + +// isCodexAvailable checks if the Codex CLI is available +func isCodexAvailable() bool { + // Try to run codex --version to check if it's available + // This is a simple check - could be enhanced + return true // For now, assume it's available +} + +// GetAgentManagerInstance returns a singleton instance of the agent manager +// This is used for backward compatibility where individual services are expected +var globalAgentManager *AgentManager + +// InitializeGlobalAgentServices initializes the global agent manager instance +func InitializeGlobalAgentServices(stateManager *WorktreeStateManager, gitService *GitService) error { + var err error + globalAgentManager, err = InitializeAgentServices(stateManager, gitService) + return err +} + +// GetGlobalAgentManager returns the global agent manager instance +func GetGlobalAgentManager() *AgentManager { + return globalAgentManager +} + +// GetClaudeServiceFromAgentManager extracts the Claude service from the agent manager for backward compatibility +func GetClaudeServiceFromAgentManager(agentManager *AgentManager) (*ClaudeService, error) { + claudeAgent, err := agentManager.GetAgent(models.AgentTypeClaude) + if err != nil { + return nil, err + } + + claudeAgentImpl, ok := claudeAgent.(*ClaudeAgent) + if !ok { + return nil, fmt.Errorf("claude agent is not of expected type") + } + + return claudeAgentImpl.service, nil +} + +// GetClaudeMonitorFromAgentManager extracts the Claude monitor from the agent manager for backward compatibility +func GetClaudeMonitorFromAgentManager(agentManager *AgentManager) (*ClaudeMonitorService, error) { + claudeMonitor, err := agentManager.GetMonitor(models.AgentTypeClaude) + if err != nil { + return nil, err + } + + claudeMonitorAdapter, ok := claudeMonitor.(*ClaudeMonitorAdapter) + if !ok { + return nil, fmt.Errorf("claude monitor is not of expected type") + } + + return claudeMonitorAdapter.monitor, nil +} + +// ShutdownAgentServices shuts down all agent services +func ShutdownAgentServices(agentManager *AgentManager) { + logger.Info("🛑 Shutting down agent services") + if agentManager != nil { + agentManager.Stop() + } +} diff --git a/container/internal/services/claude_agent.go b/container/internal/services/claude_agent.go new file mode 100644 index 00000000..829d930d --- /dev/null +++ b/container/internal/services/claude_agent.go @@ -0,0 +1,410 @@ +package services + +import ( + "context" + "io" + "time" + + "github.com/vanpelt/catnip/internal/models" +) + +// ClaudeAgent implements the Agent interface for Claude Code +type ClaudeAgent struct { + service *ClaudeService +} + +// NewClaudeAgent creates a new Claude agent +func NewClaudeAgent(service *ClaudeService) *ClaudeAgent { + return &ClaudeAgent{ + service: service, + } +} + +// GetType returns the agent type +func (ca *ClaudeAgent) GetType() models.AgentType { + return models.AgentTypeClaude +} + +// GetName returns the agent name +func (ca *ClaudeAgent) GetName() string { + return "Claude Code" +} + +// GetWorktreeSessionSummary gets Claude session summary and converts to agent format +func (ca *ClaudeAgent) GetWorktreeSessionSummary(worktreePath string) (*models.AgentSessionSummary, error) { + summary, err := ca.service.GetWorktreeSessionSummary(worktreePath) + if err != nil { + return nil, err + } + + if summary == nil { + return nil, nil + } + + // Convert Claude summary to agent summary + agentSummary := &models.AgentSessionSummary{ + WorktreePath: summary.WorktreePath, + AgentType: models.AgentTypeClaude, + SessionStartTime: summary.SessionStartTime, + SessionEndTime: summary.SessionEndTime, + TurnCount: summary.TurnCount, + IsActive: summary.IsActive, + LastSessionId: summary.LastSessionId, + CurrentSessionId: summary.CurrentSessionId, + Header: summary.Header, + LastCost: summary.LastCost, + LastDuration: summary.LastDuration, + LastTotalInputTokens: summary.LastTotalInputTokens, + LastTotalOutputTokens: summary.LastTotalOutputTokens, + } + + // Convert session list entries + if summary.AllSessions != nil { + agentSummary.AllSessions = make([]models.AgentSessionListEntry, len(summary.AllSessions)) + for i, session := range summary.AllSessions { + agentSummary.AllSessions[i] = models.AgentSessionListEntry(session) + } + } + + return agentSummary, nil +} + +// GetAllWorktreeSessionSummaries gets all Claude session summaries +func (ca *ClaudeAgent) GetAllWorktreeSessionSummaries() (map[string]*models.AgentSessionSummary, error) { + summaries, err := ca.service.GetAllWorktreeSessionSummaries() + if err != nil { + return nil, err + } + + agentSummaries := make(map[string]*models.AgentSessionSummary) + for path, summary := range summaries { + if summary != nil { + agentSummary, err := ca.GetWorktreeSessionSummary(path) + if err == nil && agentSummary != nil { + agentSummaries[path] = agentSummary + } + } + } + + return agentSummaries, nil +} + +// GetFullSessionData gets complete Claude session data +func (ca *ClaudeAgent) GetFullSessionData(worktreePath string, includeFullData bool) (*models.AgentFullSessionData, error) { + fullData, err := ca.service.GetFullSessionData(worktreePath, includeFullData) + if err != nil { + return nil, err + } + + if fullData == nil { + return nil, nil + } + + // Convert to agent format + agentFullData := &models.AgentFullSessionData{ + MessageCount: fullData.MessageCount, + } + + // Convert session info + if fullData.SessionInfo != nil { + agentSummary, err := ca.GetWorktreeSessionSummary(worktreePath) + if err == nil { + agentFullData.SessionInfo = agentSummary + } + } + + // Convert all sessions + if fullData.AllSessions != nil { + agentFullData.AllSessions = make([]models.AgentSessionListEntry, len(fullData.AllSessions)) + for i, session := range fullData.AllSessions { + agentFullData.AllSessions[i] = models.AgentSessionListEntry(session) + } + } + + // Convert messages + if fullData.Messages != nil { + agentFullData.Messages = make([]models.AgentSessionMessage, len(fullData.Messages)) + for i, msg := range fullData.Messages { + agentFullData.Messages[i] = models.AgentSessionMessage{ + AgentType: models.AgentTypeClaude, + Timestamp: msg.Timestamp, + Type: msg.Type, + Content: map[string]interface{}{"message": msg.Message}, // Wrap Claude-specific data + } + } + } + + // Convert user prompts + if fullData.UserPrompts != nil { + agentFullData.UserPrompts = make([]models.AgentHistoryEntry, len(fullData.UserPrompts)) + for i, prompt := range fullData.UserPrompts { + agentFullData.UserPrompts[i] = models.AgentHistoryEntry{ + Display: prompt.Display, + Data: map[string]interface{}{"pastedContents": prompt.PastedContents}, + } + } + } + + return agentFullData, nil +} + +// GetSessionByUUID gets Claude session by UUID +func (ca *ClaudeAgent) GetSessionByUUID(sessionUUID string) (*models.AgentFullSessionData, error) { + fullData, err := ca.service.GetSessionByUUID(sessionUUID) + if err != nil { + return nil, err + } + + if fullData == nil { + return nil, nil + } + + // Convert to agent format (similar to GetFullSessionData) + agentFullData := &models.AgentFullSessionData{ + MessageCount: fullData.MessageCount, + } + + // Convert session info + if fullData.SessionInfo != nil { + agentSummary := &models.AgentSessionSummary{ + WorktreePath: fullData.SessionInfo.WorktreePath, + AgentType: models.AgentTypeClaude, + SessionStartTime: fullData.SessionInfo.SessionStartTime, + SessionEndTime: fullData.SessionInfo.SessionEndTime, + IsActive: fullData.SessionInfo.IsActive, + CurrentSessionId: fullData.SessionInfo.CurrentSessionId, + } + agentFullData.SessionInfo = agentSummary + } + + // Convert all sessions and messages (similar to above) + if fullData.AllSessions != nil { + agentFullData.AllSessions = make([]models.AgentSessionListEntry, len(fullData.AllSessions)) + for i, session := range fullData.AllSessions { + agentFullData.AllSessions[i] = models.AgentSessionListEntry(session) + } + } + + return agentFullData, nil +} + +// GetLatestTodos gets latest todos from Claude +func (ca *ClaudeAgent) GetLatestTodos(worktreePath string) ([]models.Todo, error) { + return ca.service.GetLatestTodos(worktreePath) +} + +// GetLatestAssistantMessage gets latest assistant message from Claude +func (ca *ClaudeAgent) GetLatestAssistantMessage(worktreePath string) (string, error) { + return ca.service.GetLatestAssistantMessage(worktreePath) +} + +// GetLatestAssistantMessageOrError gets latest assistant message or error from Claude +func (ca *ClaudeAgent) GetLatestAssistantMessageOrError(worktreePath string) (content string, isError bool, err error) { + return ca.service.GetLatestAssistantMessageOrError(worktreePath) +} + +// CreateCompletion creates a Claude completion +func (ca *ClaudeAgent) CreateCompletion(ctx context.Context, req *models.AgentCompletionRequest) (*models.AgentCompletionResponse, error) { + // Convert agent request to Claude request + claudeReq := &models.CreateCompletionRequest{ + Prompt: req.Prompt, + Stream: req.Stream, + SystemPrompt: req.SystemPrompt, + Model: req.Model, + MaxTurns: req.MaxTurns, + WorkingDirectory: req.WorkingDirectory, + Resume: req.Resume, + SuppressEvents: req.SuppressEvents, + } + + resp, err := ca.service.CreateCompletion(ctx, claudeReq) + if err != nil { + return nil, err + } + + // Convert Claude response to agent response + return &models.AgentCompletionResponse{ + Response: resp.Response, + IsChunk: resp.IsChunk, + IsLast: resp.IsLast, + Error: resp.Error, + AgentType: models.AgentTypeClaude, + }, nil +} + +// CreateStreamingCompletion creates a streaming Claude completion +func (ca *ClaudeAgent) CreateStreamingCompletion(ctx context.Context, req *models.AgentCompletionRequest, responseWriter io.Writer) error { + // Convert agent request to Claude request + claudeReq := &models.CreateCompletionRequest{ + Prompt: req.Prompt, + Stream: true, // Force streaming + SystemPrompt: req.SystemPrompt, + Model: req.Model, + MaxTurns: req.MaxTurns, + WorkingDirectory: req.WorkingDirectory, + Resume: req.Resume, + SuppressEvents: req.SuppressEvents, + } + + return ca.service.CreateStreamingCompletion(ctx, claudeReq, responseWriter) +} + +// GetSettings gets Claude settings +func (ca *ClaudeAgent) GetSettings() (*models.AgentSettings, error) { + claudeSettings, err := ca.service.GetClaudeSettings() + if err != nil { + return nil, err + } + + // Convert Claude settings to agent settings + return &models.AgentSettings{ + AgentType: models.AgentTypeClaude, + IsAuthenticated: claudeSettings.IsAuthenticated, + Version: claudeSettings.Version, + HasCompletedOnboarding: claudeSettings.HasCompletedOnboarding, + NumStartups: claudeSettings.NumStartups, + NotificationsEnabled: claudeSettings.NotificationsEnabled, + AgentSpecificSettings: map[string]interface{}{ + "theme": claudeSettings.Theme, + }, + }, nil +} + +// UpdateSettings updates Claude settings +func (ca *ClaudeAgent) UpdateSettings(req *models.AgentSettingsUpdateRequest) (*models.AgentSettings, error) { + // Convert agent request to Claude request + claudeReq := &models.ClaudeSettingsUpdateRequest{ + NotificationsEnabled: req.NotificationsEnabled, + } + + // Handle agent-specific settings + if req.AgentSpecificSettings != nil { + if theme, ok := req.AgentSpecificSettings["theme"].(string); ok { + claudeReq.Theme = theme + } + } + + claudeSettings, err := ca.service.UpdateClaudeSettings(claudeReq) + if err != nil { + return nil, err + } + + // Convert back to agent settings + return &models.AgentSettings{ + AgentType: models.AgentTypeClaude, + IsAuthenticated: claudeSettings.IsAuthenticated, + Version: claudeSettings.Version, + HasCompletedOnboarding: claudeSettings.HasCompletedOnboarding, + NumStartups: claudeSettings.NumStartups, + NotificationsEnabled: claudeSettings.NotificationsEnabled, + AgentSpecificSettings: map[string]interface{}{ + "theme": claudeSettings.Theme, + }, + }, nil +} + +// UpdateActivity updates Claude activity +func (ca *ClaudeAgent) UpdateActivity(worktreePath string) { + ca.service.UpdateActivity(worktreePath) +} + +// GetLastActivity gets last Claude activity +func (ca *ClaudeAgent) GetLastActivity(worktreePath string) time.Time { + return ca.service.GetLastActivity(worktreePath) +} + +// IsActiveSession checks if Claude session is active +func (ca *ClaudeAgent) IsActiveSession(worktreePath string, within time.Duration) bool { + return ca.service.IsActiveSession(worktreePath, within) +} + +// HandleEvent handles Claude hook events +func (ca *ClaudeAgent) HandleEvent(event *models.AgentEvent) error { + // Convert agent event to Claude hook event + claudeEvent := &models.ClaudeHookEvent{ + EventType: event.EventType, + WorkingDirectory: event.WorkingDirectory, + SessionID: event.SessionID, + Data: event.Data, + } + + return ca.service.HandleHookEvent(claudeEvent) +} + +// Start starts the Claude agent +func (ca *ClaudeAgent) Start() error { + // Claude service doesn't have a start method, so this is a no-op + return nil +} + +// Stop stops the Claude agent +func (ca *ClaudeAgent) Stop() { + ca.service.Shutdown() +} + +// CleanupWorktreeFiles cleans up Claude files for a worktree +func (ca *ClaudeAgent) CleanupWorktreeFiles(worktreePath string) error { + return ca.service.CleanupWorktreeClaudeFiles(worktreePath) +} + +// IsSuppressingEvents checks if events are suppressed for a worktree +func (ca *ClaudeAgent) IsSuppressingEvents(worktreePath string) bool { + return ca.service.IsSuppressingEvents(worktreePath) +} + +// ClaudeMonitorAdapter adapts ClaudeMonitorService to implement AgentMonitor +type ClaudeMonitorAdapter struct { + monitor *ClaudeMonitorService +} + +// NewClaudeMonitorAdapter creates a new Claude monitor adapter +func NewClaudeMonitorAdapter(monitor *ClaudeMonitorService) *ClaudeMonitorAdapter { + return &ClaudeMonitorAdapter{ + monitor: monitor, + } +} + +// Start starts the Claude monitor +func (cma *ClaudeMonitorAdapter) Start() error { + return cma.monitor.Start() +} + +// Stop stops the Claude monitor +func (cma *ClaudeMonitorAdapter) Stop() { + cma.monitor.Stop() +} + +// OnWorktreeCreated handles worktree creation +func (cma *ClaudeMonitorAdapter) OnWorktreeCreated(worktreeID, worktreePath string) { + cma.monitor.OnWorktreeCreated(worktreeID, worktreePath) +} + +// OnWorktreeDeleted handles worktree deletion +func (cma *ClaudeMonitorAdapter) OnWorktreeDeleted(worktreeID, worktreePath string) { + cma.monitor.OnWorktreeDeleted(worktreeID, worktreePath) +} + +// GetLastActivityTime gets last activity time +func (cma *ClaudeMonitorAdapter) GetLastActivityTime(worktreePath string) time.Time { + return cma.monitor.GetLastActivityTime(worktreePath) +} + +// GetTodos gets todos for a worktree +func (cma *ClaudeMonitorAdapter) GetTodos(worktreePath string) ([]models.Todo, error) { + return cma.monitor.GetTodos(worktreePath) +} + +// GetActivityState gets Claude activity state +func (cma *ClaudeMonitorAdapter) GetActivityState(worktreePath string) models.ClaudeActivityState { + return cma.monitor.GetClaudeActivityState(worktreePath) +} + +// TriggerBranchRename triggers branch renaming +func (cma *ClaudeMonitorAdapter) TriggerBranchRename(workDir string, customBranchName string) error { + return cma.monitor.TriggerBranchRename(workDir, customBranchName) +} + +// RefreshTodoMonitoring refreshes todo monitoring +func (cma *ClaudeMonitorAdapter) RefreshTodoMonitoring() { + cma.monitor.RefreshTodoMonitoring() +} diff --git a/container/internal/services/codex_agent.go b/container/internal/services/codex_agent.go new file mode 100644 index 00000000..1f59bc7c --- /dev/null +++ b/container/internal/services/codex_agent.go @@ -0,0 +1,631 @@ +package services + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/vanpelt/catnip/internal/config" + "github.com/vanpelt/catnip/internal/logger" + "github.com/vanpelt/catnip/internal/models" +) + +// CodexAgent implements the Agent interface for OpenAI Codex CLI +type CodexAgent struct { + codexPath string + codexSessionsDir string + codexHistoryPath string + activityMutex sync.RWMutex + lastActivity map[string]time.Time + eventHandlers []func(*models.AgentEvent) error + eventHandlerMutex sync.RWMutex +} + +// CodexSessionMeta represents Codex session metadata +type CodexSessionMeta struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + Cwd string `json:"cwd"` + Originator string `json:"originator"` + CLIVersion string `json:"cli_version"` + Instructions string `json:"instructions"` + Git struct { + CommitHash string `json:"commit_hash"` + Branch string `json:"branch"` + RepositoryURL string `json:"repository_url"` + } `json:"git"` +} + +// CodexMessage represents a Codex message +type CodexMessage struct { + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Payload struct { + Type string `json:"type"` + Role string `json:"role,omitempty"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content,omitempty"` + ID string `json:"id,omitempty"` + CLI string `json:"cli_version,omitempty"` + MetaData *CodexSessionMeta `json:",omitempty"` // For session_meta type + } `json:"payload"` +} + +// CodexHistoryEntry represents an entry in the Codex history file +type CodexHistoryEntry struct { + SessionID string `json:"session_id"` + Timestamp int64 `json:"ts"` + Text string `json:"text"` +} + +// NewCodexAgent creates a new Codex agent +func NewCodexAgent() *CodexAgent { + homeDir := config.Runtime.HomeDir + return &CodexAgent{ + codexPath: "codex", // Assume it's in PATH + codexSessionsDir: filepath.Join(homeDir, ".codex", "sessions"), + codexHistoryPath: filepath.Join(homeDir, ".codex", "history.jsonl"), + lastActivity: make(map[string]time.Time), + } +} + +// GetType returns the agent type +func (ca *CodexAgent) GetType() models.AgentType { + return models.AgentTypeCodex +} + +// GetName returns the agent name +func (ca *CodexAgent) GetName() string { + return "OpenAI Codex CLI" +} + +// GetWorktreeSessionSummary gets Codex session summary for a worktree +func (ca *CodexAgent) GetWorktreeSessionSummary(worktreePath string) (*models.AgentSessionSummary, error) { + // Find the most recent session for this worktree + sessionFile, sessionMeta, err := ca.findLatestSessionForWorktree(worktreePath) + if err != nil { + return nil, err + } + + if sessionFile == "" { + return nil, nil // No session found + } + + // Parse session file to get message count and timing + messageCount, startTime, endTime, err := ca.parseSessionMetrics(sessionFile) + if err != nil { + return nil, err + } + + // Get latest title from session + title, err := ca.getSessionTitle(sessionFile) + if err != nil { + title = "" + } + + summary := &models.AgentSessionSummary{ + WorktreePath: worktreePath, + AgentType: models.AgentTypeCodex, + SessionStartTime: startTime, + SessionEndTime: endTime, + TurnCount: messageCount, + IsActive: endTime == nil, // Active if no end time + CurrentSessionId: &sessionMeta.ID, + Header: &title, + } + + // Get all sessions for this worktree + allSessions, err := ca.getAllSessionsForWorktree(worktreePath) + if err == nil { + summary.AllSessions = allSessions + } + + return summary, nil +} + +// GetAllWorktreeSessionSummaries gets all Codex session summaries +func (ca *CodexAgent) GetAllWorktreeSessionSummaries() (map[string]*models.AgentSessionSummary, error) { + summaries := make(map[string]*models.AgentSessionSummary) + + // Walk through all session files and group by worktree + err := filepath.Walk(ca.codexSessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + + // Parse session to get worktree path + worktreePath, err := ca.getWorktreePathFromSession(path) + if err != nil { + return nil // Skip invalid sessions + } + + // Only include the latest session for each worktree + if _, exists := summaries[worktreePath]; !exists { + summary, err := ca.GetWorktreeSessionSummary(worktreePath) + if err == nil && summary != nil { + summaries[worktreePath] = summary + } + } + + return nil + }) + + return summaries, err +} + +// GetFullSessionData gets complete Codex session data +func (ca *CodexAgent) GetFullSessionData(worktreePath string, includeFullData bool) (*models.AgentFullSessionData, error) { + summary, err := ca.GetWorktreeSessionSummary(worktreePath) + if err != nil { + return nil, err + } + + if summary == nil { + return nil, nil + } + + fullData := &models.AgentFullSessionData{ + SessionInfo: summary, + AllSessions: summary.AllSessions, + } + + if includeFullData && summary.CurrentSessionId != nil { + // Get full messages for the session + sessionFile, err := ca.findSessionFile(*summary.CurrentSessionId) + if err == nil && sessionFile != "" { + messages, err := ca.parseSessionMessages(sessionFile) + if err == nil { + fullData.Messages = messages + fullData.MessageCount = len(messages) + } + } + + // Get user prompts from history + userPrompts, err := ca.getUserPrompts(worktreePath) + if err == nil { + fullData.UserPrompts = userPrompts + } + } + + return fullData, nil +} + +// GetSessionByUUID gets Codex session by UUID +func (ca *CodexAgent) GetSessionByUUID(sessionUUID string) (*models.AgentFullSessionData, error) { + sessionFile, err := ca.findSessionFile(sessionUUID) + if err != nil { + return nil, err + } + + if sessionFile == "" { + return nil, fmt.Errorf("session not found: %s", sessionUUID) + } + + // Get worktree path from session + worktreePath, err := ca.getWorktreePathFromSession(sessionFile) + if err != nil { + return nil, err + } + + return ca.GetFullSessionData(worktreePath, true) +} + +// GetLatestTodos gets latest todos from Codex session +func (ca *CodexAgent) GetLatestTodos(worktreePath string) ([]models.Todo, error) { + sessionFile, _, err := ca.findLatestSessionForWorktree(worktreePath) + if err != nil || sessionFile == "" { + return []models.Todo{}, nil + } + + return ca.extractTodosFromSession(sessionFile) +} + +// GetLatestAssistantMessage gets latest assistant message from Codex +func (ca *CodexAgent) GetLatestAssistantMessage(worktreePath string) (string, error) { + sessionFile, _, err := ca.findLatestSessionForWorktree(worktreePath) + if err != nil || sessionFile == "" { + return "", nil + } + + return ca.getLatestAssistantMessageFromSession(sessionFile) +} + +// GetLatestAssistantMessageOrError gets latest assistant message or error from Codex +func (ca *CodexAgent) GetLatestAssistantMessageOrError(worktreePath string) (content string, isError bool, err error) { + message, err := ca.GetLatestAssistantMessage(worktreePath) + if err != nil { + return "", false, err + } + + // Check if message contains error patterns + lowerMessage := strings.ToLower(message) + isError = strings.Contains(lowerMessage, "error") || + strings.Contains(lowerMessage, "failed") || + strings.Contains(lowerMessage, "unavailable") + + return message, isError, nil +} + +// CreateCompletion creates a Codex completion +func (ca *CodexAgent) CreateCompletion(ctx context.Context, req *models.AgentCompletionRequest) (*models.AgentCompletionResponse, error) { + // Build codex command + args := []string{} + + // Set working directory + workingDir := req.WorkingDirectory + if workingDir == "" { + workingDir = filepath.Join(config.Runtime.WorkspaceDir, "current") + } else { + workingDir = config.Runtime.ResolvePath(workingDir) + } + + // Add resume flag if requested + if req.Resume { + args = append(args, "--resume") + } + + // Create command + cmd := exec.CommandContext(ctx, ca.codexPath, args...) + cmd.Dir = workingDir + cmd.Env = os.Environ() + + // Set up pipes + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdin pipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Start the command + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start codex command: %w", err) + } + + // Send prompt + go func() { + defer stdin.Close() + if _, err := stdin.Write([]byte(req.Prompt)); err != nil { + logger.Errorf("Failed to write prompt to codex: %v", err) + } + }() + + // Read response + output, err := io.ReadAll(stdout) + if err != nil { + return nil, fmt.Errorf("failed to read stdout: %w", err) + } + + // Read stderr + stderrOutput, _ := io.ReadAll(stderr) + + // Wait for command to complete + if err := cmd.Wait(); err != nil { + return &models.AgentCompletionResponse{ + Error: string(stderrOutput), + AgentType: models.AgentTypeCodex, + }, fmt.Errorf("codex command failed: %s", string(stderrOutput)) + } + + return &models.AgentCompletionResponse{ + Response: string(output), + IsChunk: false, + IsLast: true, + AgentType: models.AgentTypeCodex, + }, nil +} + +// CreateStreamingCompletion creates a streaming Codex completion +func (ca *CodexAgent) CreateStreamingCompletion(ctx context.Context, req *models.AgentCompletionRequest, responseWriter io.Writer) error { + // For now, use non-streaming and write response at once + // TODO: Implement true streaming if Codex CLI supports it + resp, err := ca.CreateCompletion(ctx, req) + if err != nil { + return err + } + + // Write response as a single chunk + responseJSON, err := json.Marshal(resp) + if err != nil { + return err + } + + _, err = responseWriter.Write(append(responseJSON, '\n')) + return err +} + +// GetSettings gets Codex settings (minimal implementation) +func (ca *CodexAgent) GetSettings() (*models.AgentSettings, error) { + // Codex doesn't have the same settings system as Claude + // Return basic settings + return &models.AgentSettings{ + AgentType: models.AgentTypeCodex, + IsAuthenticated: true, // Assume authenticated if codex command works + Version: "", // Could be detected from CLI version + HasCompletedOnboarding: true, + NumStartups: 0, + NotificationsEnabled: true, // Default to enabled + AgentSpecificSettings: map[string]interface{}{}, + }, nil +} + +// UpdateSettings updates Codex settings (minimal implementation) +func (ca *CodexAgent) UpdateSettings(req *models.AgentSettingsUpdateRequest) (*models.AgentSettings, error) { + // Codex doesn't have settings to update, so just return current settings + return ca.GetSettings() +} + +// UpdateActivity updates Codex activity +func (ca *CodexAgent) UpdateActivity(worktreePath string) { + ca.activityMutex.Lock() + ca.lastActivity[worktreePath] = time.Now() + ca.activityMutex.Unlock() +} + +// GetLastActivity gets last Codex activity +func (ca *CodexAgent) GetLastActivity(worktreePath string) time.Time { + ca.activityMutex.RLock() + defer ca.activityMutex.RUnlock() + return ca.lastActivity[worktreePath] +} + +// IsActiveSession checks if Codex session is active +func (ca *CodexAgent) IsActiveSession(worktreePath string, within time.Duration) bool { + lastActivity := ca.GetLastActivity(worktreePath) + if lastActivity.IsZero() { + return false + } + return time.Since(lastActivity) <= within +} + +// HandleEvent handles Codex events (no-op since Codex doesn't have hooks) +func (ca *CodexAgent) HandleEvent(event *models.AgentEvent) error { + // Codex doesn't have native hooks, but we can still handle events + // that might be generated by our file watchers + ca.eventHandlerMutex.RLock() + defer ca.eventHandlerMutex.RUnlock() + + for _, handler := range ca.eventHandlers { + if err := handler(event); err != nil { + logger.Warnf("Codex event handler error: %v", err) + } + } + + return nil +} + +// AddEventHandler adds an event handler for Codex events +func (ca *CodexAgent) AddEventHandler(handler func(*models.AgentEvent) error) { + ca.eventHandlerMutex.Lock() + defer ca.eventHandlerMutex.Unlock() + ca.eventHandlers = append(ca.eventHandlers, handler) +} + +// Start starts the Codex agent +func (ca *CodexAgent) Start() error { + // Ensure directories exist + if err := os.MkdirAll(ca.codexSessionsDir, 0755); err != nil { + return fmt.Errorf("failed to create codex sessions directory: %w", err) + } + + logger.Infof("🚀 Codex agent started") + return nil +} + +// Stop stops the Codex agent +func (ca *CodexAgent) Stop() { + logger.Infof("🛑 Codex agent stopped") +} + +// CleanupWorktreeFiles cleans up Codex files for a worktree +func (ca *CodexAgent) CleanupWorktreeFiles(worktreePath string) error { + // Remove session files for this worktree + // This is more complex for Codex since we need to find sessions by worktree path + var errs []error + + err := filepath.Walk(ca.codexSessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + + // Check if this session belongs to the worktree + sessionWorktreePath, err := ca.getWorktreePathFromSession(path) + if err != nil { + return nil + } + + if sessionWorktreePath == worktreePath { + if err := os.Remove(path); err != nil { + errs = append(errs, err) + } + } + + return nil + }) + + if err != nil { + errs = append(errs, err) + } + + // Clear activity tracking + ca.activityMutex.Lock() + delete(ca.lastActivity, worktreePath) + ca.activityMutex.Unlock() + + if len(errs) > 0 { + return fmt.Errorf("cleanup errors: %v", errs) + } + + return nil +} + +// Helper methods for Codex-specific functionality + +// findLatestSessionForWorktree finds the most recent Codex session for a worktree +func (ca *CodexAgent) findLatestSessionForWorktree(worktreePath string) (string, *CodexSessionMeta, error) { + var latestFile string + var latestTime time.Time + var latestMeta *CodexSessionMeta + + err := filepath.Walk(ca.codexSessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + + // Parse session to check if it's for this worktree + sessionWorktreePath, sessionMeta, err := ca.getWorktreePathAndMetaFromSession(path) + if err != nil { + return nil + } + + if sessionWorktreePath == worktreePath && sessionMeta.Timestamp.After(latestTime) { + latestFile = path + latestTime = sessionMeta.Timestamp + latestMeta = sessionMeta + } + + return nil + }) + + return latestFile, latestMeta, err +} + +// getWorktreePathFromSession extracts the worktree path from a Codex session file +func (ca *CodexAgent) getWorktreePathFromSession(sessionFile string) (string, error) { + path, _, err := ca.getWorktreePathAndMetaFromSession(sessionFile) + return path, err +} + +// getWorktreePathAndMetaFromSession extracts worktree path and metadata from a session file +func (ca *CodexAgent) getWorktreePathAndMetaFromSession(sessionFile string) (string, *CodexSessionMeta, error) { + file, err := os.Open(sessionFile) + if err != nil { + return "", nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var msg CodexMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + continue + } + + if msg.Type == "session_meta" && msg.Payload.MetaData != nil { + return msg.Payload.MetaData.Cwd, msg.Payload.MetaData, nil + } + } + + return "", nil, fmt.Errorf("no session metadata found in %s", sessionFile) +} + +// Additional helper methods would go here... +// (parseSessionMetrics, getSessionTitle, getAllSessionsForWorktree, etc.) + +// parseSessionMetrics parses basic metrics from a Codex session file +func (ca *CodexAgent) parseSessionMetrics(sessionFile string) (messageCount int, startTime *time.Time, endTime *time.Time, err error) { + file, err := os.Open(sessionFile) + if err != nil { + return 0, nil, nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var firstTime, lastTime time.Time + + for scanner.Scan() { + var msg CodexMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + continue + } + + timestamp, err := time.Parse(time.RFC3339, msg.Timestamp) + if err != nil { + continue + } + + if firstTime.IsZero() { + firstTime = timestamp + } + lastTime = timestamp + + if msg.Type == "response_item" { + messageCount++ + } + } + + if !firstTime.IsZero() { + startTime = &firstTime + } + if !lastTime.IsZero() && !lastTime.Equal(firstTime) { + endTime = &lastTime + } + + return messageCount, startTime, endTime, nil +} + +// Placeholder implementations for other helper methods +func (ca *CodexAgent) getSessionTitle(sessionFile string) (string, error) { + // TODO: Extract title from session content + return "Codex Session", nil +} + +func (ca *CodexAgent) getAllSessionsForWorktree(worktreePath string) ([]models.AgentSessionListEntry, error) { + // TODO: Implement session listing + return []models.AgentSessionListEntry{}, nil +} + +func (ca *CodexAgent) findSessionFile(sessionUUID string) (string, error) { + // TODO: Find session file by UUID + return "", fmt.Errorf("not implemented") +} + +func (ca *CodexAgent) parseSessionMessages(sessionFile string) ([]models.AgentSessionMessage, error) { + // TODO: Parse all messages from session + return []models.AgentSessionMessage{}, nil +} + +func (ca *CodexAgent) getUserPrompts(worktreePath string) ([]models.AgentHistoryEntry, error) { + // TODO: Get user prompts from history + return []models.AgentHistoryEntry{}, nil +} + +func (ca *CodexAgent) extractTodosFromSession(sessionFile string) ([]models.Todo, error) { + // TODO: Extract todos from session content + return []models.Todo{}, nil +} + +func (ca *CodexAgent) getLatestAssistantMessageFromSession(sessionFile string) (string, error) { + // TODO: Get latest assistant message + return "", nil +} diff --git a/container/internal/services/codex_monitor.go b/container/internal/services/codex_monitor.go new file mode 100644 index 00000000..8f56c166 --- /dev/null +++ b/container/internal/services/codex_monitor.go @@ -0,0 +1,602 @@ +package services + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/vanpelt/catnip/internal/logger" + "github.com/vanpelt/catnip/internal/models" +) + +// CodexMonitor monitors Codex sessions and simulates hook events +type CodexMonitor struct { + codexAgent *CodexAgent + stateManager *WorktreeStateManager + gitService *GitService + watcher *fsnotify.Watcher + stopCh chan struct{} + sessionWatchers map[string]*CodexSessionWatcher + watchersMutex sync.RWMutex + eventHandlers []func(*models.AgentEvent) error + eventHandlerMutex sync.RWMutex +} + +// CodexSessionWatcher watches a single Codex session for changes +type CodexSessionWatcher struct { + sessionFile string + worktreePath string + sessionID string + lastSize int64 + lastModTime time.Time + lastTodos []models.Todo + // lastTitle string // unused but kept for potential future use + isActive bool + startTime time.Time + stopCh chan struct{} + monitor *CodexMonitor +} + +// NewCodexMonitor creates a new Codex monitor +func NewCodexMonitor(codexAgent *CodexAgent, stateManager *WorktreeStateManager, gitService *GitService) *CodexMonitor { + return &CodexMonitor{ + codexAgent: codexAgent, + stateManager: stateManager, + gitService: gitService, + stopCh: make(chan struct{}), + sessionWatchers: make(map[string]*CodexSessionWatcher), + } +} + +// Start starts the Codex monitor +func (cm *CodexMonitor) Start() error { + logger.Infof("🚀 Starting Codex monitor service") + + // Create file watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create file watcher: %w", err) + } + cm.watcher = watcher + + // Watch the Codex sessions directory + sessionsDir := cm.codexAgent.codexSessionsDir + if err := os.MkdirAll(sessionsDir, 0755); err != nil { + return fmt.Errorf("failed to create sessions directory: %w", err) + } + + // Add recursive watching for the sessions directory + if err := cm.addRecursiveWatch(sessionsDir); err != nil { + return fmt.Errorf("failed to watch sessions directory: %w", err) + } + + // Watch the history file + historyDir := filepath.Dir(cm.codexAgent.codexHistoryPath) + if err := os.MkdirAll(historyDir, 0755); err != nil { + return fmt.Errorf("failed to create history directory: %w", err) + } + + if err := cm.watcher.Add(historyDir); err != nil { + return fmt.Errorf("failed to watch history directory: %w", err) + } + + // Start the file watching goroutine + go cm.watchFiles() + + // Start monitoring existing sessions + go cm.scanExistingSessions() + + return nil +} + +// Stop stops the Codex monitor +func (cm *CodexMonitor) Stop() { + logger.Info("🛑 Stopping Codex monitor service") + close(cm.stopCh) + + if cm.watcher != nil { + cm.watcher.Close() + } + + // Stop all session watchers + cm.watchersMutex.Lock() + for _, watcher := range cm.sessionWatchers { + close(watcher.stopCh) + } + cm.sessionWatchers = make(map[string]*CodexSessionWatcher) + cm.watchersMutex.Unlock() +} + +// OnWorktreeCreated handles worktree creation +func (cm *CodexMonitor) OnWorktreeCreated(worktreeID, worktreePath string) { + logger.Debugf("📂 Codex monitor: worktree created %s -> %s", worktreeID, worktreePath) + // Start monitoring for new sessions in this worktree + go cm.scanForWorktreeSessions(worktreePath) +} + +// OnWorktreeDeleted handles worktree deletion +func (cm *CodexMonitor) OnWorktreeDeleted(worktreeID, worktreePath string) { + logger.Debugf("📂 Codex monitor: worktree deleted %s -> %s", worktreeID, worktreePath) + + // Stop watching sessions for this worktree + cm.watchersMutex.Lock() + defer cm.watchersMutex.Unlock() + + for sessionID, watcher := range cm.sessionWatchers { + if watcher.worktreePath == worktreePath { + close(watcher.stopCh) + delete(cm.sessionWatchers, sessionID) + } + } +} + +// GetLastActivityTime gets last activity time for a worktree +func (cm *CodexMonitor) GetLastActivityTime(worktreePath string) time.Time { + return cm.codexAgent.GetLastActivity(worktreePath) +} + +// GetTodos gets todos for a worktree +func (cm *CodexMonitor) GetTodos(worktreePath string) ([]models.Todo, error) { + return cm.codexAgent.GetLatestTodos(worktreePath) +} + +// GetActivityState gets activity state for a worktree +func (cm *CodexMonitor) GetActivityState(worktreePath string) models.ClaudeActivityState { + // Check if there's an active session + cm.watchersMutex.RLock() + defer cm.watchersMutex.RUnlock() + + now := time.Now() + for _, watcher := range cm.sessionWatchers { + if watcher.worktreePath == worktreePath && watcher.isActive { + // Check recent activity + if now.Sub(watcher.lastModTime) <= 3*time.Minute { + return models.ClaudeActive + } else if now.Sub(watcher.lastModTime) <= 10*time.Minute { + return models.ClaudeRunning + } + } + } + + return models.ClaudeInactive +} + +// TriggerBranchRename triggers branch renaming (not implemented for Codex) +func (cm *CodexMonitor) TriggerBranchRename(workDir string, customBranchName string) error { + // Codex doesn't have automatic branch renaming like Claude + // This could be implemented by analyzing session content and using git operations + return fmt.Errorf("branch renaming not implemented for Codex") +} + +// RefreshTodoMonitoring refreshes todo monitoring +func (cm *CodexMonitor) RefreshTodoMonitoring() { + logger.Debugf("🔄 Refreshing Codex todo monitoring") + go cm.scanExistingSessions() +} + +// AddEventHandler adds an event handler +func (cm *CodexMonitor) AddEventHandler(handler func(*models.AgentEvent) error) { + cm.eventHandlerMutex.Lock() + defer cm.eventHandlerMutex.Unlock() + cm.eventHandlers = append(cm.eventHandlers, handler) +} + +// emitEvent emits a Codex event +func (cm *CodexMonitor) emitEvent(event *models.AgentEvent) { + // Update activity + cm.codexAgent.UpdateActivity(event.WorkingDirectory) + + // Call event handlers + cm.eventHandlerMutex.RLock() + defer cm.eventHandlerMutex.RUnlock() + + for _, handler := range cm.eventHandlers { + if err := handler(event); err != nil { + logger.Warnf("Codex event handler error: %v", err) + } + } + + // Also notify the agent + if err := cm.codexAgent.HandleEvent(event); err != nil { + logger.Errorf("Failed to handle agent event: %v", err) + } +} + +// watchFiles watches for file system changes +func (cm *CodexMonitor) watchFiles() { + for { + select { + case event, ok := <-cm.watcher.Events: + if !ok { + return + } + cm.handleFileEvent(event) + + case err, ok := <-cm.watcher.Errors: + if !ok { + return + } + logger.Warnf("⚠️ Codex file watcher error: %v", err) + + case <-cm.stopCh: + return + } + } +} + +// handleFileEvent handles a file system event +func (cm *CodexMonitor) handleFileEvent(event fsnotify.Event) { + logger.Debugf("📁 Codex file event: %s %s", event.Op, event.Name) + + // Handle session file changes + if strings.HasSuffix(event.Name, ".jsonl") && strings.Contains(event.Name, cm.codexAgent.codexSessionsDir) { + if event.Op&fsnotify.Create == fsnotify.Create { + cm.handleNewSessionFile(event.Name) + } else if event.Op&fsnotify.Write == fsnotify.Write { + cm.handleSessionFileUpdate(event.Name) + } + } + + // Handle history file changes + if event.Name == cm.codexAgent.codexHistoryPath && event.Op&fsnotify.Write == fsnotify.Write { + cm.handleHistoryUpdate() + } + + // Handle new directories (for date-based organization) + if event.Op&fsnotify.Create == fsnotify.Create { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + if strings.Contains(event.Name, cm.codexAgent.codexSessionsDir) { + if err := cm.watcher.Add(event.Name); err != nil { + logger.Errorf("Failed to add new session file to watcher: %v", err) + } + } + } + } +} + +// handleNewSessionFile handles creation of a new session file +func (cm *CodexMonitor) handleNewSessionFile(sessionFile string) { + logger.Debugf("📝 New Codex session file: %s", sessionFile) + + // Parse session to get metadata + worktreePath, sessionMeta, err := cm.codexAgent.getWorktreePathAndMetaFromSession(sessionFile) + if err != nil { + logger.Warnf("⚠️ Failed to parse new session file: %v", err) + return + } + + // Create session watcher + watcher := &CodexSessionWatcher{ + sessionFile: sessionFile, + worktreePath: worktreePath, + sessionID: sessionMeta.ID, + isActive: true, + startTime: time.Now(), + stopCh: make(chan struct{}), + monitor: cm, + } + + // Add to watchers map + cm.watchersMutex.Lock() + cm.sessionWatchers[sessionMeta.ID] = watcher + cm.watchersMutex.Unlock() + + // Start watching this session + go watcher.watch() + + // Emit SessionStart event + cm.emitEvent(&models.AgentEvent{ + EventType: "SessionStart", + WorkingDirectory: worktreePath, + SessionID: sessionMeta.ID, + AgentType: models.AgentTypeCodex, + Timestamp: time.Now(), + Data: map[string]interface{}{ + "session_file": sessionFile, + "cli_version": sessionMeta.CLIVersion, + }, + }) +} + +// handleSessionFileUpdate handles updates to an existing session file +func (cm *CodexMonitor) handleSessionFileUpdate(sessionFile string) { + // Find the session watcher for this file + cm.watchersMutex.RLock() + var watcher *CodexSessionWatcher + for _, w := range cm.sessionWatchers { + if w.sessionFile == sessionFile { + watcher = w + break + } + } + cm.watchersMutex.RUnlock() + + if watcher != nil { + watcher.handleUpdate() + } +} + +// handleHistoryUpdate handles updates to the history file +func (cm *CodexMonitor) handleHistoryUpdate() { + logger.Debugf("📝 Codex history file updated") + + // Read the latest entry from history + entry, err := cm.getLatestHistoryEntry() + if err != nil { + logger.Warnf("⚠️ Failed to read latest history entry: %v", err) + return + } + + if entry != nil { + // Emit UserPromptSubmit event + cm.emitEvent(&models.AgentEvent{ + EventType: "UserPromptSubmit", + WorkingDirectory: "", // Will be filled by finding the session + SessionID: entry.SessionID, + AgentType: models.AgentTypeCodex, + Timestamp: time.Unix(entry.Timestamp, 0), + Data: map[string]interface{}{ + "prompt": entry.Text, + }, + }) + } +} + +// scanExistingSessions scans for existing sessions and starts monitoring them +func (cm *CodexMonitor) scanExistingSessions() { + logger.Debugf("🔍 Scanning existing Codex sessions") + + err := filepath.Walk(cm.codexAgent.codexSessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + + // Check if we're already watching this session + sessionID := cm.extractSessionIDFromPath(path) + cm.watchersMutex.RLock() + _, exists := cm.sessionWatchers[sessionID] + cm.watchersMutex.RUnlock() + + if !exists && cm.isRecentSession(info.ModTime()) { + cm.handleNewSessionFile(path) + } + + return nil + }) + + if err != nil { + logger.Warnf("⚠️ Error scanning existing sessions: %v", err) + } +} + +// scanForWorktreeSessions scans for sessions belonging to a specific worktree +func (cm *CodexMonitor) scanForWorktreeSessions(worktreePath string) { + logger.Debugf("🔍 Scanning Codex sessions for worktree: %s", worktreePath) + + err := filepath.Walk(cm.codexAgent.codexSessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + + // Check if this session belongs to the worktree + sessionWorktreePath, err := cm.codexAgent.getWorktreePathFromSession(path) + if err != nil { + return nil + } + + if sessionWorktreePath == worktreePath && cm.isRecentSession(info.ModTime()) { + sessionID := cm.extractSessionIDFromPath(path) + cm.watchersMutex.RLock() + _, exists := cm.sessionWatchers[sessionID] + cm.watchersMutex.RUnlock() + + if !exists { + cm.handleNewSessionFile(path) + } + } + + return nil + }) + + if err != nil { + logger.Warnf("⚠️ Error scanning worktree sessions: %v", err) + } +} + +// addRecursiveWatch adds watchers for a directory and all subdirectories +func (cm *CodexMonitor) addRecursiveWatch(dir string) error { + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + if info.IsDir() { + return cm.watcher.Add(path) + } + + return nil + }) +} + +// Helper methods + +func (cm *CodexMonitor) extractSessionIDFromPath(path string) string { + filename := filepath.Base(path) + // Extract UUID from filename like "rollout-2025-09-17T11-22-47-87bdc047-fc68-4279-9a34-8e51531f361f.jsonl" + parts := strings.Split(filename, "-") + if len(parts) >= 5 { + // UUID is typically the last 5 parts before .jsonl + uuid := strings.Join(parts[len(parts)-5:], "-") + return strings.TrimSuffix(uuid, ".jsonl") + } + return strings.TrimSuffix(filename, ".jsonl") +} + +func (cm *CodexMonitor) isRecentSession(modTime time.Time) bool { + // Consider sessions from the last 24 hours as potentially active + return time.Since(modTime) <= 24*time.Hour +} + +func (cm *CodexMonitor) getLatestHistoryEntry() (*CodexHistoryEntry, error) { + file, err := os.Open(cm.codexAgent.codexHistoryPath) + if err != nil { + return nil, err + } + defer file.Close() + + var lastEntry *CodexHistoryEntry + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var entry CodexHistoryEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + lastEntry = &entry + } + + return lastEntry, nil +} + +// CodexSessionWatcher methods + +// watch monitors a single session file for changes +func (w *CodexSessionWatcher) watch() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + w.checkForUpdates() + case <-w.stopCh: + return + } + } +} + +// handleUpdate handles a file system update to the session file +func (w *CodexSessionWatcher) handleUpdate() { + w.checkForUpdates() +} + +// checkForUpdates checks for updates to the session file +func (w *CodexSessionWatcher) checkForUpdates() { + info, err := os.Stat(w.sessionFile) + if err != nil { + return // File might be deleted + } + + // Check if file has been modified + if !info.ModTime().After(w.lastModTime) { + return + } + + w.lastModTime = info.ModTime() + + // Check if file size has changed significantly + if info.Size() <= w.lastSize { + return + } + + w.lastSize = info.Size() + + // Parse the session file for new content + w.parseSessionUpdates() + + // If we haven't seen activity for a while, mark session as stopped + if time.Since(w.lastModTime) > 5*time.Minute && w.isActive { + w.isActive = false + w.emitStopEvent() + } +} + +// parseSessionUpdates parses the session file for new content +func (w *CodexSessionWatcher) parseSessionUpdates() { + // Read the session file and look for new messages + file, err := os.Open(w.sessionFile) + if err != nil { + return + } + defer file.Close() + + var hasNewAssistantMessage bool + var newTodos []models.Todo + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var msg CodexMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + continue + } + + // Check for assistant messages (PostToolUse equivalent) + if msg.Type == "response_item" && msg.Payload.Type == "message" && msg.Payload.Role == "assistant" { + hasNewAssistantMessage = true + } + + // Extract todos from content (if any) + // TODO: Implement todo extraction from Codex messages + } + + // Emit events based on what we found + if hasNewAssistantMessage { + w.emitPostToolUseEvent() + } + + // Check for todo changes + if len(newTodos) > 0 && !w.todosEqual(w.lastTodos, newTodos) { + w.lastTodos = newTodos + // TODO: Emit todo update event + } +} + +// emitPostToolUseEvent emits a PostToolUse event +func (w *CodexSessionWatcher) emitPostToolUseEvent() { + w.monitor.emitEvent(&models.AgentEvent{ + EventType: "PostToolUse", + WorkingDirectory: w.worktreePath, + SessionID: w.sessionID, + AgentType: models.AgentTypeCodex, + Timestamp: time.Now(), + Data: map[string]interface{}{}, + }) +} + +// emitStopEvent emits a Stop event +func (w *CodexSessionWatcher) emitStopEvent() { + w.monitor.emitEvent(&models.AgentEvent{ + EventType: "Stop", + WorkingDirectory: w.worktreePath, + SessionID: w.sessionID, + AgentType: models.AgentTypeCodex, + Timestamp: time.Now(), + Data: map[string]interface{}{}, + }) +} + +// todosEqual compares two todo slices +func (w *CodexSessionWatcher) todosEqual(a, b []models.Todo) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Content != b[i].Content || a[i].Status != b[i].Status { + return false + } + } + return true +}