diff --git a/.gitignore b/.gitignore index 590f864..f1a306c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ dist/ # Local notes thoughts/ *.lock + +# IntelliJ Plugin +intellij-plugin/build/ +intellij-plugin/.gradle/ diff --git a/README.md b/README.md index e68a82c..2f7190f 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,10 @@ Pass options via the `args` property in your MCP config: Run `uvx agent-task-queue@latest --help` to see all options. +## IntelliJ Plugin + +An optional [IntelliJ plugin](intellij-plugin/) provides real-time IDE integration — status bar widget, tool window with live streaming output, and balloon notifications for queue events. See the [plugin README](intellij-plugin/README.md) for details. + ## Architecture ```mermaid @@ -344,7 +348,9 @@ The queue state is stored in SQLite at `/tmp/agent-task-queue/queue.db`: | `id` | INTEGER | Auto-incrementing primary key | | `queue_name` | TEXT | Queue identifier (e.g., "global", "android") | | `status` | TEXT | Task state: "waiting" or "running" | +| `command` | TEXT | Shell command being executed | | `pid` | INTEGER | MCP server process ID (for liveness check) | +| `server_id` | TEXT | Server instance UUID (for orphan detection across PID reuse) | | `child_pid` | INTEGER | Subprocess ID (for orphan cleanup) | | `created_at` | TIMESTAMP | When task was queued | | `updated_at` | TIMESTAMP | Last status change | @@ -383,23 +389,29 @@ To reduce token usage, full command output is written to files instead of return ``` /tmp/agent-task-queue/output/ -├── task_1.log +├── task_1.log # Formatted log with metadata and section markers +├── task_1.raw.log # Raw stdout+stderr only (for plugin streaming) ├── task_2.log +├── task_2.raw.log └── ... ``` +Each task produces two output files: +- **`task_.log`** — Formatted log with headers (`COMMAND:`, `WORKING DIR:`), section markers (`--- STDOUT ---`, `--- STDERR ---`, `--- SUMMARY ---`), and exit code. Used by the IntelliJ plugin notifier and the "View Output" action. +- **`task_.raw.log`** — Raw stdout+stderr only, no metadata. Used by the IntelliJ plugin for clean streaming output in tabs. Added in MCP server v0.4.0. + **On success**, the tool returns a single line: ``` -SUCCESS exit=0 31.2s output=/tmp/agent-task-queue/output/task_8.log +SUCCESS exit=0 31.2s command=./gradlew build output=/tmp/agent-task-queue/output/task_8.log ``` **On failure**, the last 50 lines of output are included: ``` -FAILED exit=1 12.5s output=/tmp/agent-task-queue/output/task_9.log +FAILED exit=1 12.5s command=./gradlew build output=/tmp/agent-task-queue/output/task_9.log [error output here] ``` -**Automatic cleanup**: Old files are deleted when count exceeds 50 (configurable via `MAX_OUTPUT_FILES`). +**Automatic cleanup**: Old files are deleted when count exceeds 50 tasks (configurable via `--max-output-files`). **Manual cleanup**: Use the `clear_task_logs` tool to delete all output files. diff --git a/intellij-plugin/README.md b/intellij-plugin/README.md new file mode 100644 index 0000000..d358bce --- /dev/null +++ b/intellij-plugin/README.md @@ -0,0 +1,171 @@ +# IntelliJ Plugin + +An IntelliJ IDEA plugin that provides real-time visibility into the agent-task-queue system. Shows running/waiting tasks in a status bar widget and tool window, with streaming output, notifications, and task management. + +## Features + +### Status Bar Widget + +Displays current queue state in the IDE status bar with four configurable display modes: + +| Mode | Shows | +|------|-------| +| **Hidden** | Nothing — widget invisible | +| **Minimal** | Icon only | +| **Default** | `Task Queue: ./gradlew build (+2)` | +| **Verbose** | `Task Queue: ./gradlew build [2m 13s] (+2 waiting)` | + +Click the widget to open the tool window. Configure the display mode in **Settings > Tools > Agent Task Queue**. + +### Tool Window + +- **Queue** tab — Table of all tasks with ID, status, queue name, command, and relative time. Toolbar actions for refresh, cancel, clear, view output, and settings. Click a running task row to open its output tab. +- **Output** tabs — Per-task closeable tabs with live streaming console output. Automatically opened when a task starts running. Tabs can be closed and reopened by clicking the running task in the queue table. + +### Notifications + +Balloon notifications for queue events (can be disabled in settings): + +| Event | Type | Content | +|-------|------|---------| +| Task starts running | Info balloon | "Running: `./gradlew build`" | +| Task finishes (exit 0) | Info balloon | "Finished: `./gradlew build`" | +| Task fails (exit != 0) | Error balloon (sticky) | "Failed: `./gradlew build`" + View Output action | + +Failure detection works by reading the `EXIT CODE` from the formatted task log (`task_.log`) after it disappears from the queue. + +## Architecture + +### How It Reads Data + +The plugin reads the SQLite database directly (read-only via JDBC with WAL mode) rather than going through the MCP server. This avoids coupling to the MCP protocol and lets the plugin work even when no MCP server is running. + +``` +TaskQueuePoller (1-3s interval) + └── TaskQueueDatabase.fetchAllTasks() + └── SELECT * FROM queue ORDER BY queue_name, id + └── jdbc:sqlite:/tmp/agent-task-queue/queue.db +``` + +### Polling Strategy + +Two independent polling loops, each active only when needed: + +**Database poller** (`TaskQueuePoller`) — Polls the SQLite queue database: +- 1s interval when tasks exist (active) +- 3s interval when queue is empty (idle) +- Supports manual refresh via a conflated coroutine channel +- Detects stale tasks by checking if the server PID is still alive (`kill -0`), and removes them from the DB + +**Output file tailer** (`OutputStreamer`) — Tails the running task's output file: +- Only active while a task is running (no coroutine exists otherwise) +- 50ms interval when new data was just read (active streaming) +- 200ms interval when no new data (waiting for output) +- Uses `RandomAccessFile` with byte offset tracking to read only new content +- Prefers `task_.raw.log` (MCP server v0.4.0+) for clean output with no filtering +- Falls back to `task_.log` with header skipping and marker filtering for MCP server v0.3.x and earlier + +We chose polling over `java.nio.file.WatchService` because WatchService on macOS falls back to internal polling at 2-10s intervals (no native kqueue support for file modifications in Java), which would actually be slower. + +### Data Flow + +``` +TaskQueuePoller ──poll()──> TaskQueueDatabase ──SQL──> SQLite DB + │ + └── TaskQueueModel.update(tasks) + │ + └── messageBus.syncPublisher(TOPIC) + │ + ├── TaskQueueStatusBarWidget.updateLabel() + ├── TaskQueuePanel (table + summary) + ├── OutputPanel ──start/stopTailing──> OutputStreamer + └── TaskQueueNotifier (balloon notifications) +``` + +All UI components subscribe to `TaskQueueModel.TOPIC` on the IntelliJ message bus and react to changes. The model publishes updates on the EDT via `invokeLater`. + +### Process Cancellation + +Task cancellation sends SIGTERM to the process group (negative PID), waits 500ms, then sends SIGKILL if still alive. The Python task runner uses `start_new_session=True` when spawning subprocesses, which creates a dedicated process group — this ensures `kill -TERM -` cleanly terminates the entire process tree. + +The UI is updated optimistically — the task is removed from the model immediately so the table responds instantly, before the background process kill and DB cleanup complete. The poller reconciles with the DB on subsequent polls. + +## Database Schema + +The plugin reads from the `queue` table: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INTEGER | Auto-incrementing primary key | +| `queue_name` | TEXT | Queue identifier (e.g., "global") | +| `status` | TEXT | "waiting" or "running" | +| `command` | TEXT | Shell command being executed | +| `pid` | INTEGER | MCP server process ID | +| `child_pid` | INTEGER | Subprocess group ID (used for cancellation) | +| `created_at` | TIMESTAMP | When task was queued | +| `updated_at` | TIMESTAMP | Last status change | + +Output logs are at `/output/task_.log` (formatted) and `/output/task_.raw.log` (raw output, MCP server v0.4.0+). + +## Building + +```bash +cd intellij-plugin +./gradlew buildPlugin +``` + +The built plugin ZIP is at `build/distributions/`. + +### Requirements + +- JDK 21+ +- IntelliJ IDEA 2024.2+ (build 242-252.*) + +### Dependencies + +- `org.xerial:sqlite-jdbc:3.47.2.0` — SQLite JDBC driver +- Kotlin coroutines — bundled with IntelliJ Platform (do NOT add as a dependency) + +## Settings + +Persisted in `AgentTaskQueueSettings.xml`: + +| Setting | Default | Description | +|---------|---------|-------------| +| `dataDir` | `$TASK_QUEUE_DATA_DIR` or `/tmp/agent-task-queue` | Path to agent-task-queue data directory | +| `displayMode` | `default` | Status bar display: `hidden`, `minimal`, `default`, `verbose` | +| `notificationsEnabled` | `true` | Show balloon notifications for queue events | + +## Project Structure + +``` +src/main/kotlin/com/block/agenttaskqueue/ +├── TaskQueueIcons.kt # Icon loading +├── actions/ +│ ├── CancelTaskAction.kt # Cancel selected task +│ ├── ClearQueueAction.kt # Clear all tasks +│ ├── OpenOutputLogAction.kt # Open log file in editor +│ ├── OpenSettingsAction.kt # Open settings page +│ ├── RefreshQueueAction.kt # Manual refresh +│ └── TaskQueueDataKeys.kt # DataKey for selected task +├── data/ +│ ├── OutputStreamer.kt # Coroutine file tailer +│ ├── TaskCanceller.kt # Process group termination +│ ├── TaskQueueDatabase.kt # SQLite JDBC access +│ ├── TaskQueueNotifier.kt # Balloon notifications +│ └── TaskQueuePoller.kt # Background DB polling +├── model/ +│ ├── QueueSummary.kt # Aggregate counts +│ ├── QueueTask.kt # Task data class +│ └── TaskQueueModel.kt # Shared state + message bus topic +├── settings/ +│ ├── TaskQueueConfigurable.kt # Settings UI +│ └── TaskQueueSettings.kt # Persistent state +└── ui/ + ├── OutputPanel.kt # Live console output tab + ├── TaskQueuePanel.kt # Queue table tab + ├── TaskQueueStatusBarWidget.kt + ├── TaskQueueStatusBarWidgetFactory.kt + ├── TaskQueueTableModel.kt # Table data model + └── TaskQueueToolWindowFactory.kt +``` diff --git a/intellij-plugin/build.gradle.kts b/intellij-plugin/build.gradle.kts new file mode 100644 index 0000000..49e4f66 --- /dev/null +++ b/intellij-plugin/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "1.9.25" + id("org.jetbrains.intellij.platform") version "2.2.1" +} + +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdeaCommunity(providers.gradleProperty("platformVersion").get()) + } + + implementation("org.xerial:sqlite-jdbc:3.47.2.0") +} + +intellijPlatform { + pluginConfiguration { + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + untilBuild = providers.gradleProperty("pluginUntilBuild") + } + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks { + withType { + kotlinOptions.jvmTarget = "21" + } +} diff --git a/intellij-plugin/gradle.properties b/intellij-plugin/gradle.properties new file mode 100644 index 0000000..14e14e8 --- /dev/null +++ b/intellij-plugin/gradle.properties @@ -0,0 +1,11 @@ +pluginGroup = com.block.agenttaskqueue +pluginName = Agent Task Queue +pluginVersion = 0.1.0 +pluginSinceBuild = 242 +pluginUntilBuild = 252.* + +platformType = IC +platformVersion = 2024.2 + +org.gradle.jvmargs = -Xmx2g +kotlin.stdlib.default.dependency = false diff --git a/intellij-plugin/gradle/wrapper/gradle-wrapper.jar b/intellij-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/intellij-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/intellij-plugin/gradle/wrapper/gradle-wrapper.properties b/intellij-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/intellij-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/intellij-plugin/gradlew b/intellij-plugin/gradlew new file mode 100755 index 0000000..d95bf61 --- /dev/null +++ b/intellij-plugin/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/intellij-plugin/gradlew.bat b/intellij-plugin/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/intellij-plugin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/intellij-plugin/settings.gradle.kts b/intellij-plugin/settings.gradle.kts new file mode 100644 index 0000000..5d19f7b --- /dev/null +++ b/intellij-plugin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "agent-task-queue-plugin" diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/TaskQueueIcons.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/TaskQueueIcons.kt new file mode 100644 index 0000000..1dd2da0 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/TaskQueueIcons.kt @@ -0,0 +1,9 @@ +package com.block.agenttaskqueue + +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +object TaskQueueIcons { + @JvmField + val TaskQueue: Icon = IconLoader.getIcon("/icons/taskQueue.svg", TaskQueueIcons::class.java) +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/CancelTaskAction.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/CancelTaskAction.kt new file mode 100644 index 0000000..bf15257 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/CancelTaskAction.kt @@ -0,0 +1,30 @@ +package com.block.agenttaskqueue.actions + +import com.block.agenttaskqueue.data.TaskCanceller +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages + +class CancelTaskAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.dataContext.getData(TaskQueueDataKeys.SELECTED_TASK) != null + } + + override fun actionPerformed(e: AnActionEvent) { + val task = e.dataContext.getData(TaskQueueDataKeys.SELECTED_TASK) ?: return + val project = e.project + val result = Messages.showYesNoDialog( + project, + "Cancel task #${task.id}?", + "Cancel Task", + Messages.getQuestionIcon() + ) + if (result == Messages.YES) { + TaskCanceller.getInstance().cancelTask(task) + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/ClearQueueAction.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/ClearQueueAction.kt new file mode 100644 index 0000000..6c27ec7 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/ClearQueueAction.kt @@ -0,0 +1,34 @@ +package com.block.agenttaskqueue.actions + +import com.block.agenttaskqueue.data.TaskCanceller +import com.block.agenttaskqueue.model.TaskQueueModel +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages + +class ClearQueueAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = TaskQueueModel.getInstance().tasks.isNotEmpty() + } + + override fun actionPerformed(e: AnActionEvent) { + val tasks = TaskQueueModel.getInstance().tasks + if (tasks.isEmpty()) return + + val runningCount = tasks.count { it.status == "running" } + val message = "Clear all ${tasks.size} tasks? $runningCount running task(s) will be killed." + val result = Messages.showYesNoDialog( + e.project, + message, + "Clear Queue", + Messages.getWarningIcon() + ) + if (result == Messages.YES) { + TaskCanceller.getInstance().clearAllTasks(tasks) + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenOutputLogAction.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenOutputLogAction.kt new file mode 100644 index 0000000..a210395 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenOutputLogAction.kt @@ -0,0 +1,36 @@ +package com.block.agenttaskqueue.actions + +import com.block.agenttaskqueue.settings.TaskQueueSettings +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.vfs.LocalFileSystem + +class OpenOutputLogAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.dataContext.getData(TaskQueueDataKeys.SELECTED_TASK) != null + } + + override fun actionPerformed(e: AnActionEvent) { + val task = e.dataContext.getData(TaskQueueDataKeys.SELECTED_TASK) ?: return + val project = e.project ?: return + + val path = "${TaskQueueSettings.getInstance().outputDir}/task_${task.id}.log" + val file = LocalFileSystem.getInstance().findFileByPath(path) + + if (file != null) { + FileEditorManager.getInstance(project).openFile(file, true) + } else { + Messages.showInfoMessage( + project, + "Output log not found: $path", + "Log Not Found" + ) + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenSettingsAction.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenSettingsAction.kt new file mode 100644 index 0000000..690c639 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenSettingsAction.kt @@ -0,0 +1,13 @@ +package com.block.agenttaskqueue.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.options.ShowSettingsUtil + +class OpenSettingsAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + ShowSettingsUtil.getInstance().showSettingsDialog( + e.project, "Agent Task Queue" + ) + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/RefreshQueueAction.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/RefreshQueueAction.kt new file mode 100644 index 0000000..b2ecba3 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/RefreshQueueAction.kt @@ -0,0 +1,12 @@ +package com.block.agenttaskqueue.actions + +import com.block.agenttaskqueue.data.TaskQueuePoller +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class RefreshQueueAction : AnAction() { + + override fun actionPerformed(e: AnActionEvent) { + TaskQueuePoller.getInstance().refreshNow() + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/TaskQueueDataKeys.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/TaskQueueDataKeys.kt new file mode 100644 index 0000000..cc2c1ca --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/TaskQueueDataKeys.kt @@ -0,0 +1,8 @@ +package com.block.agenttaskqueue.actions + +import com.block.agenttaskqueue.model.QueueTask +import com.intellij.openapi.actionSystem.DataKey + +object TaskQueueDataKeys { + val SELECTED_TASK: DataKey = DataKey.create("AgentTaskQueue.SelectedTask") +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt new file mode 100644 index 0000000..bfb6aee --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt @@ -0,0 +1,172 @@ +package com.block.agenttaskqueue.data + +import com.intellij.openapi.diagnostic.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import java.io.RandomAccessFile + +/** + * Tails task output files and streams content to the output panel. + * + * Supports two output formats: + * - task_.raw.log — raw stdout+stderr with no metadata. Added in MCP server v0.4.0. + * Preferred; tailed directly from offset 0 with no filtering. + * - task_.log — formatted log with COMMAND/STDOUT/STDERR/SUMMARY markers. + * Written by all MCP server versions. Used as fallback for MCP server v0.3.x and earlier + * which don't write .raw.log files. Requires skipping the header and filtering out + * section markers. + */ +class OutputStreamer( + private val scope: CoroutineScope, + private val onContent: (String) -> Unit, + private val onClear: () -> Unit, +) { + + companion object { + private val LOG = Logger.getInstance(OutputStreamer::class.java) + private const val ACTIVE_POLL_MS = 50L + private const val IDLE_POLL_MS = 200L + // Fallback constants for MCP server v0.3.x and earlier that only write .log files + private const val STDOUT_MARKER = "--- STDOUT ---\n" + private const val MAX_FALLBACK_WAIT_MS = 2000L + } + + private var currentTaskId: Int? = null + private var currentLogPath: String? = null + private var fileOffset: Long = 0 + private var tailJob: Job? = null + private var useFallback = false + + fun startTailing(taskId: Int, logFilePath: String) { + if (taskId == currentTaskId) return + stopTailing() + currentTaskId = taskId + currentLogPath = logFilePath + fileOffset = 0 + useFallback = false + onClear() + + tailJob = scope.launch(Dispatchers.IO) { + // Prefer .raw.log, fall back to .log (with filtering) for old servers + val rawFile = File(logFilePath) + val fallbackFile = File(logFilePath.removeSuffix(".raw.log") + ".log") + var file: File? = null + var waited = 0L + + // Wait briefly for the raw file to appear; fall back to formatted log + while (isActive && file == null) { + if (rawFile.exists()) { + file = rawFile + } else if (waited >= MAX_FALLBACK_WAIT_MS && fallbackFile.exists()) { + file = fallbackFile + useFallback = true + LOG.info("Falling back to formatted log for task $taskId") + } else { + delay(IDLE_POLL_MS) + waited += IDLE_POLL_MS + } + } + + if (file == null) return@launch + + // For fallback mode, skip past the header to the STDOUT marker + if (useFallback) { + while (isActive) { + val content = file.readText(Charsets.UTF_8) + val markerIdx = content.indexOf(STDOUT_MARKER) + if (markerIdx >= 0) { + fileOffset = (markerIdx + STDOUT_MARKER.length).toLong() + break + } + delay(IDLE_POLL_MS) + } + } + + while (isActive) { + var hadNewData = false + try { + if (file.length() > fileOffset) { + RandomAccessFile(file, "r").use { raf -> + raf.seek(fileOffset) + val bytes = ByteArray((raf.length() - fileOffset).toInt()) + raf.readFully(bytes) + fileOffset = raf.length() + val text = if (useFallback) { + filterContent(String(bytes, Charsets.UTF_8)) + } else { + String(bytes, Charsets.UTF_8) + } + if (text.isNotEmpty()) { + onContent(text) + hadNewData = true + } + } + } + } catch (e: Exception) { + LOG.debug("Error tailing log file: ${file.path}", e) + } + delay(if (hadNewData) ACTIVE_POLL_MS else IDLE_POLL_MS) + } + } + } + + fun finishTailing() { + tailJob?.cancel() + tailJob = null + // Flush any remaining bytes written after the task finished + val path = currentLogPath ?: return + val file = if (useFallback) { + File(path.removeSuffix(".raw.log") + ".log") + } else { + File(path) + } + try { + if (file.exists() && file.length() > fileOffset) { + RandomAccessFile(file, "r").use { raf -> + raf.seek(fileOffset) + val bytes = ByteArray((raf.length() - fileOffset).toInt()) + raf.readFully(bytes) + fileOffset = raf.length() + val text = if (useFallback) { + filterContent(String(bytes, Charsets.UTF_8)) + } else { + String(bytes, Charsets.UTF_8) + } + if (text.isNotEmpty()) { + onContent(text) + } + } + } + } catch (e: Exception) { + LOG.debug("Error flushing log file: ${file.path}", e) + } + currentTaskId = null + currentLogPath = null + fileOffset = 0 + useFallback = false + } + + fun stopTailing() { + tailJob?.cancel() + tailJob = null + currentTaskId = null + currentLogPath = null + fileOffset = 0 + useFallback = false + } + + private fun filterContent(text: String): String { + var result = text + val summaryIdx = result.indexOf("--- SUMMARY ---") + if (summaryIdx >= 0) { + result = result.substring(0, summaryIdx).trimEnd('\n') + } + result = result.replace("--- STDERR ---\n", "") + return result + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt new file mode 100644 index 0000000..909ba5d --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt @@ -0,0 +1,84 @@ +package com.block.agenttaskqueue.data + +import com.block.agenttaskqueue.model.QueueTask +import com.block.agenttaskqueue.model.TaskQueueModel +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger + +@Service(Service.Level.APP) +class TaskCanceller { + + companion object { + private val LOG = Logger.getInstance(TaskCanceller::class.java) + + fun getInstance(): TaskCanceller = + ApplicationManager.getApplication().getService(TaskCanceller::class.java) + } + + fun cancelTask(task: QueueTask) { + // Optimistic UI update — remove from model immediately so the table updates + // without waiting for the next DB poll (which races with process cleanup) + val model = TaskQueueModel.getInstance() + model.update(model.tasks.filter { it.id != task.id }) + + ApplicationManager.getApplication().executeOnPooledThread { + try { + if (task.status == "running" && task.childPid != null) { + killProcessGroup(task.childPid) + } + TaskQueueDatabase.getInstance().deleteTask(task.id) + } catch (e: Exception) { + LOG.warn("Failed to cancel task #${task.id}", e) + } + TaskQueuePoller.getInstance().refreshNow() + } + } + + fun clearAllTasks(tasks: List) { + // Optimistic UI update — clear the model immediately + TaskQueueModel.getInstance().update(emptyList()) + + ApplicationManager.getApplication().executeOnPooledThread { + try { + for (task in tasks) { + if (task.status == "running" && task.childPid != null) { + killProcessGroup(task.childPid) + } + } + TaskQueueDatabase.getInstance().deleteAllTasks() + } catch (e: Exception) { + LOG.warn("Failed to clear queue", e) + } + TaskQueuePoller.getInstance().refreshNow() + } + } + + private fun killProcessGroup(pid: Int) { + // Use negative PID to target the process group (works on both macOS and Linux) + try { + ProcessBuilder("kill", "-TERM", "-$pid") + .redirectErrorStream(true) + .start() + .waitFor() + } catch (e: Exception) { + LOG.warn("Failed to send SIGTERM to process group -$pid", e) + } + + try { + Thread.sleep(500) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return + } + + try { + ProcessBuilder("kill", "-9", "-$pid") + .redirectErrorStream(true) + .start() + .waitFor() + } catch (e: Exception) { + LOG.debug("SIGKILL to process group -$pid failed (process may already be dead)", e) + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueDatabase.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueDatabase.kt new file mode 100644 index 0000000..5e76faa --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueDatabase.kt @@ -0,0 +1,82 @@ +package com.block.agenttaskqueue.data + +import com.block.agenttaskqueue.model.QueueTask +import com.block.agenttaskqueue.settings.TaskQueueSettings +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import java.io.File +import java.sql.Connection +import java.sql.DriverManager + +@Service(Service.Level.APP) +class TaskQueueDatabase { + + companion object { + private val LOG = Logger.getInstance(TaskQueueDatabase::class.java) + + init { + try { + Class.forName("org.sqlite.JDBC") + } catch (e: ClassNotFoundException) { + LOG.error("SQLite JDBC driver not found", e) + } + } + + fun getInstance(): TaskQueueDatabase = + ApplicationManager.getApplication().getService(TaskQueueDatabase::class.java) + } + + private fun getConnection(): Connection? { + val dbPath = TaskQueueSettings.getInstance().dbPath + if (!File(dbPath).exists()) { + LOG.debug("Database file not found: $dbPath") + return null + } + + val conn = DriverManager.getConnection("jdbc:sqlite:$dbPath") + conn.createStatement().execute("PRAGMA journal_mode=WAL") + conn.createStatement().execute("PRAGMA busy_timeout=5000") + return conn + } + + fun fetchAllTasks(): List { + val conn = getConnection() ?: return emptyList() + return conn.use { c -> + val stmt = c.createStatement() + val rs = stmt.executeQuery("SELECT * FROM queue ORDER BY queue_name, id") + val tasks = mutableListOf() + while (rs.next()) { + tasks.add( + QueueTask( + id = rs.getInt("id"), + queueName = rs.getString("queue_name"), + status = rs.getString("status"), + command = rs.getString("command"), + pid = rs.getObject("pid") as? Int, + childPid = rs.getObject("child_pid") as? Int, + createdAt = rs.getString("created_at"), + updatedAt = rs.getString("updated_at"), + ) + ) + } + tasks + } + } + + fun deleteTask(id: Int) { + val conn = getConnection() ?: return + conn.use { c -> + val stmt = c.prepareStatement("DELETE FROM queue WHERE id = ?") + stmt.setInt(1, id) + stmt.executeUpdate() + } + } + + fun deleteAllTasks() { + val conn = getConnection() ?: return + conn.use { c -> + c.createStatement().executeUpdate("DELETE FROM queue") + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueNotifier.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueNotifier.kt new file mode 100644 index 0000000..d3e6be8 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueNotifier.kt @@ -0,0 +1,127 @@ +package com.block.agenttaskqueue.data + +import com.block.agenttaskqueue.model.QueueSummary +import com.block.agenttaskqueue.model.QueueTask +import com.block.agenttaskqueue.model.TaskQueueListener +import com.block.agenttaskqueue.model.TaskQueueModel +import com.block.agenttaskqueue.settings.TaskQueueSettings +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import java.io.File + +@Service(Service.Level.APP) +class TaskQueueNotifier { + + companion object { + private val LOG = Logger.getInstance(TaskQueueNotifier::class.java) + private const val NOTIFICATION_GROUP_ID = "Agent Task Queue" + + fun getInstance(): TaskQueueNotifier = + ApplicationManager.getApplication().getService(TaskQueueNotifier::class.java) + } + + private var previousTaskIds = emptySet() + private var previousRunningIds = emptySet() + private var previousCommands = emptyMap() + + init { + ApplicationManager.getApplication().messageBus.connect() + .subscribe(TaskQueueModel.TOPIC, object : TaskQueueListener { + override fun onQueueUpdated(tasks: List, summary: QueueSummary) { + processUpdate(tasks) + } + }) + } + + private fun processUpdate(tasks: List) { + if (!TaskQueueSettings.getInstance().notificationsEnabled) { + updateTracking(tasks) + return + } + + val currentTaskIds = tasks.map { it.id }.toSet() + val currentRunningIds = tasks.filter { it.status == "running" }.map { it.id }.toSet() + val currentCommands = tasks.associate { it.id to (it.command ?: "unknown") } + + // Detect newly running tasks + val newlyRunning = currentRunningIds - previousRunningIds + for (id in newlyRunning) { + // Only notify if this task existed before (was waiting) or is brand new + val cmd = currentCommands[id] ?: "unknown" + notify("Running: $cmd", NotificationType.INFORMATION) + } + + // Detect disappeared tasks (finished) + val disappeared = previousTaskIds - currentTaskIds + for (id in disappeared) { + val cmd = previousCommands[id] ?: "unknown" + // Only notify for tasks that were running (not waiting tasks that got cancelled) + if (id in previousRunningIds) { + val exitCode = readExitCode(id) + if (exitCode != null && exitCode != 0) { + notifyWithAction("Failed: $cmd (exit code $exitCode)", NotificationType.ERROR, id) + } else { + notify("Finished: $cmd", NotificationType.INFORMATION) + } + } + } + + updateTracking(tasks) + } + + private fun updateTracking(tasks: List) { + previousTaskIds = tasks.map { it.id }.toSet() + previousRunningIds = tasks.filter { it.status == "running" }.map { it.id }.toSet() + previousCommands = tasks.associate { it.id to (it.command ?: "unknown") } + } + + private fun readExitCode(taskId: Int): Int? { + return try { + val outputDir = TaskQueueSettings.getInstance().outputDir + val logFile = File("$outputDir/task_$taskId.log") + if (!logFile.exists()) return null + val content = logFile.readText() + val match = Regex("""EXIT CODE:\s*(\d+)""").find(content) + match?.groupValues?.get(1)?.toIntOrNull() + } catch (e: Exception) { + LOG.debug("Failed to read exit code for task $taskId", e) + null + } + } + + private fun notify(content: String, type: NotificationType) { + NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification(content, type) + .notify(null) + } + + private fun notifyWithAction(content: String, type: NotificationType, taskId: Int) { + val outputDir = TaskQueueSettings.getInstance().outputDir + val logPath = "$outputDir/task_$taskId.log" + + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification(content, type) + .setImportant(true) + + notification.addAction(object : com.intellij.notification.NotificationAction("View Output") { + override fun actionPerformed( + e: com.intellij.openapi.actionSystem.AnActionEvent, + notification: com.intellij.notification.Notification + ) { + val project = e.project ?: return + val vf = com.intellij.openapi.vfs.LocalFileSystem.getInstance().findFileByPath(logPath) + if (vf != null) { + com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project).openFile(vf, true) + } + notification.expire() + } + }) + + notification.notify(null) + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt new file mode 100644 index 0000000..e83221e --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt @@ -0,0 +1,89 @@ +package com.block.agenttaskqueue.data + +import com.block.agenttaskqueue.model.TaskQueueModel +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +@Service(Service.Level.APP) +class TaskQueuePoller : Disposable { + + companion object { + private val LOG = Logger.getInstance(TaskQueuePoller::class.java) + private const val ACTIVE_INTERVAL_MS = 1000L + private const val IDLE_INTERVAL_MS = 3000L + + fun getInstance(): TaskQueuePoller = + ApplicationManager.getApplication().getService(TaskQueuePoller::class.java) + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val refreshChannel = Channel(Channel.CONFLATED) + private var previousTasks = emptyList() + + init { + Disposer.register(ApplicationManager.getApplication(), this) + scope.launch { + while (true) { + poll() + val interval = if (previousTasks.isNotEmpty()) ACTIVE_INTERVAL_MS else IDLE_INTERVAL_MS + // Wait for the interval, but wake up early if refreshNow() is called + withTimeoutOrNull(interval) { + refreshChannel.receive() + } + } + } + } + + private fun poll() { + try { + val db = TaskQueueDatabase.getInstance() + var tasks = db.fetchAllTasks() + + // Clean up stale tasks whose server process is no longer alive + val staleTasks = tasks.filter { it.pid != null && !isProcessAlive(it.pid) } + if (staleTasks.isNotEmpty()) { + for (task in staleTasks) { + LOG.info("Removing stale task #${task.id} (pid ${task.pid} is dead)") + db.deleteTask(task.id) + } + tasks = tasks - staleTasks.toSet() + } + + if (tasks != previousTasks) { + previousTasks = tasks + TaskQueueModel.getInstance().update(tasks) + } + } catch (e: Exception) { + LOG.warn("Failed to poll task queue", e) + } + } + + private fun isProcessAlive(pid: Int): Boolean { + return try { + // kill -0 checks if process exists without sending a signal + val process = ProcessBuilder("kill", "-0", pid.toString()) + .redirectErrorStream(true) + .start() + process.waitFor() == 0 + } catch (e: Exception) { + false + } + } + + fun refreshNow() { + refreshChannel.trySend(Unit) + } + + override fun dispose() { + scope.coroutineContext[kotlinx.coroutines.Job.Key]?.cancel() + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueSummary.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueSummary.kt new file mode 100644 index 0000000..0b2ecf8 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueSummary.kt @@ -0,0 +1,19 @@ +package com.block.agenttaskqueue.model + +data class QueueSummary( + val total: Int, + val running: Int, + val waiting: Int, +) { + companion object { + val EMPTY = QueueSummary(total = 0, running = 0, waiting = 0) + + fun fromTasks(tasks: List): QueueSummary { + return QueueSummary( + total = tasks.size, + running = tasks.count { it.status == "running" }, + waiting = tasks.count { it.status == "waiting" }, + ) + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueTask.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueTask.kt new file mode 100644 index 0000000..191a36c --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueTask.kt @@ -0,0 +1,16 @@ +package com.block.agenttaskqueue.model + +data class QueueTask( + val id: Int, + val queueName: String, + val status: String, + val command: String?, + val pid: Int?, + val childPid: Int?, + val createdAt: String?, + val updatedAt: String?, +) { + /** Command with leading KEY=value env var prefixes stripped. */ + val displayCommand: String + get() = (command ?: "unknown").replace(Regex("^(\\w+=\\S+\\s+)+"), "") +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/TaskQueueModel.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/TaskQueueModel.kt new file mode 100644 index 0000000..f86c84d --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/TaskQueueModel.kt @@ -0,0 +1,42 @@ +package com.block.agenttaskqueue.model + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.util.messages.Topic + +interface TaskQueueListener { + fun onQueueUpdated(tasks: List, summary: QueueSummary) +} + +@Service(Service.Level.APP) +class TaskQueueModel { + + companion object { + val TOPIC = Topic.create("AgentTaskQueue.Update", TaskQueueListener::class.java) + + fun getInstance(): TaskQueueModel = + ApplicationManager.getApplication().getService(TaskQueueModel::class.java) + } + + @Volatile + var tasks: List = emptyList() + private set + + @Volatile + var summary: QueueSummary = QueueSummary.EMPTY + private set + + fun update(newTasks: List) { + tasks = newTasks + summary = QueueSummary.fromTasks(newTasks) + notifyListeners() + } + + fun notifyListeners() { + ApplicationManager.getApplication().invokeLater { + ApplicationManager.getApplication().messageBus + .syncPublisher(TOPIC) + .onQueueUpdated(tasks, summary) + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt new file mode 100644 index 0000000..c294691 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt @@ -0,0 +1,79 @@ +package com.block.agenttaskqueue.settings + +import com.block.agenttaskqueue.model.TaskQueueModel +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.FormBuilder +import javax.swing.DefaultComboBoxModel +import javax.swing.JComponent +import javax.swing.JPanel + +class TaskQueueConfigurable : Configurable { + + private var panel: JPanel? = null + private var dataDirField: TextFieldWithBrowseButton? = null + private var displayModeCombo: ComboBox? = null + private var notificationsCheckbox: JBCheckBox? = null + + private val displayModes = arrayOf("hidden", "minimal", "default", "verbose") + private val displayModeLabels = arrayOf("Hidden", "Minimal (icon only)", "Default", "Verbose (with elapsed time)") + + override fun getDisplayName(): String = "Agent Task Queue" + + override fun createComponent(): JComponent { + val settings = TaskQueueSettings.getInstance() + + dataDirField = TextFieldWithBrowseButton().apply { + text = settings.dataDir + addBrowseFolderListener("Select Data Directory", "Choose the agent-task-queue data directory", null, + com.intellij.openapi.fileChooser.FileChooserDescriptorFactory.createSingleFolderDescriptor()) + } + + displayModeCombo = ComboBox(DefaultComboBoxModel(displayModeLabels)).apply { + selectedIndex = displayModes.indexOf(settings.displayMode).coerceAtLeast(0) + } + + notificationsCheckbox = JBCheckBox("Enable notifications", settings.notificationsEnabled) + + panel = FormBuilder.createFormBuilder() + .addLabeledComponent(JBLabel("Data directory:"), dataDirField!!, 1, false) + .addLabeledComponent(JBLabel("Status bar display:"), displayModeCombo!!, 1, false) + .addComponent(notificationsCheckbox!!, 1) + .addComponentFillVertically(JPanel(), 0) + .panel + + return panel!! + } + + override fun isModified(): Boolean { + val settings = TaskQueueSettings.getInstance() + return dataDirField?.text != settings.dataDir + || displayModes.getOrNull(displayModeCombo?.selectedIndex ?: -1) != settings.displayMode + || notificationsCheckbox?.isSelected != settings.notificationsEnabled + } + + override fun apply() { + val settings = TaskQueueSettings.getInstance() + settings.dataDir = dataDirField?.text ?: return + settings.displayMode = displayModes.getOrNull(displayModeCombo?.selectedIndex ?: -1) ?: "default" + settings.notificationsEnabled = notificationsCheckbox?.isSelected ?: true + TaskQueueModel.getInstance().notifyListeners() + } + + override fun reset() { + val settings = TaskQueueSettings.getInstance() + dataDirField?.text = settings.dataDir + displayModeCombo?.selectedIndex = displayModes.indexOf(settings.displayMode).coerceAtLeast(0) + notificationsCheckbox?.isSelected = settings.notificationsEnabled + } + + override fun disposeUIResources() { + panel = null + dataDirField = null + displayModeCombo = null + notificationsCheckbox = null + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueSettings.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueSettings.kt new file mode 100644 index 0000000..ab3c814 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueSettings.kt @@ -0,0 +1,55 @@ +package com.block.agenttaskqueue.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage + +@Service(Service.Level.APP) +@State(name = "AgentTaskQueueSettings", storages = [Storage("AgentTaskQueueSettings.xml")]) +class TaskQueueSettings : PersistentStateComponent { + + data class State( + var dataDir: String = System.getenv("TASK_QUEUE_DATA_DIR") ?: "/tmp/agent-task-queue", + var displayMode: String = "default", + var notificationsEnabled: Boolean = true, + ) + + private var state = State() + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state + } + + var dataDir: String + get() = state.dataDir + set(value) { + state.dataDir = value + } + + var displayMode: String + get() = state.displayMode + set(value) { + state.displayMode = value + } + + var notificationsEnabled: Boolean + get() = state.notificationsEnabled + set(value) { + state.notificationsEnabled = value + } + + val dbPath: String + get() = "$dataDir/queue.db" + + val outputDir: String + get() = "$dataDir/output" + + companion object { + fun getInstance(): TaskQueueSettings = + ApplicationManager.getApplication().getService(TaskQueueSettings::class.java) + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt new file mode 100644 index 0000000..ffe73d5 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt @@ -0,0 +1,65 @@ +package com.block.agenttaskqueue.ui + +import com.block.agenttaskqueue.data.OutputStreamer +import com.block.agenttaskqueue.model.QueueSummary +import com.block.agenttaskqueue.model.QueueTask +import com.block.agenttaskqueue.model.TaskQueueListener +import com.block.agenttaskqueue.model.TaskQueueModel +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import java.awt.BorderLayout +import javax.swing.JPanel + +class OutputPanel( + private val project: Project, + val taskId: Int, + logFilePath: String, +) : JPanel(BorderLayout()), Disposable { + + private val consoleView = + TextConsoleBuilderFactory.getInstance().createBuilder(project).console + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var finished = false + + private val streamer = OutputStreamer( + scope = scope, + onContent = { text -> + ApplicationManager.getApplication().invokeLater { + consoleView.print(text, ConsoleViewContentType.NORMAL_OUTPUT) + } + }, + onClear = { + ApplicationManager.getApplication().invokeLater { + consoleView.clear() + } + }, + ) + + init { + add(consoleView.component, BorderLayout.CENTER) + streamer.startTailing(taskId, logFilePath) + + // Watch for this task to disappear (finished) so we flush remaining output + project.messageBus.connect(this).subscribe(TaskQueueModel.TOPIC, object : TaskQueueListener { + override fun onQueueUpdated(tasks: List, summary: QueueSummary) { + if (!finished && tasks.none { it.id == taskId }) { + finished = true + streamer.finishTailing() + } + } + }) + } + + override fun dispose() { + streamer.stopTailing() + scope.coroutineContext[kotlinx.coroutines.Job.Key]?.cancel() + Disposer.dispose(consoleView) + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt new file mode 100644 index 0000000..3d77818 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt @@ -0,0 +1,86 @@ +package com.block.agenttaskqueue.ui + +import com.block.agenttaskqueue.actions.TaskQueueDataKeys +import com.block.agenttaskqueue.data.TaskQueuePoller +import com.block.agenttaskqueue.model.QueueSummary +import com.block.agenttaskqueue.model.QueueTask +import com.block.agenttaskqueue.model.TaskQueueListener +import com.block.agenttaskqueue.model.TaskQueueModel +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.table.JBTable +import java.awt.BorderLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JPanel + +class TaskQueuePanel(private val project: Project) : JPanel(BorderLayout()), DataProvider { + + private val tableModel = TaskQueueTableModel() + private val table = JBTable(tableModel) + private val summaryLabel = JBLabel("Queue is empty") + var onTaskClicked: ((QueueTask) -> Unit)? = null + + init { + val group = ActionManager.getInstance().getAction("AgentTaskQueueToolbar") as ActionGroup + val toolbar = ActionManager.getInstance().createActionToolbar("AgentTaskQueueToolbar", group, true) + toolbar.targetComponent = this + add(toolbar.component, BorderLayout.NORTH) + + // Column sizing: #, Status, Queue are fixed-width; Command gets the remaining space + table.columnModel.getColumn(0).preferredWidth = 40 // # + table.columnModel.getColumn(0).maxWidth = 60 + table.columnModel.getColumn(1).preferredWidth = 70 // Status + table.columnModel.getColumn(1).maxWidth = 90 + table.columnModel.getColumn(2).preferredWidth = 80 // Queue + table.columnModel.getColumn(2).maxWidth = 120 + table.columnModel.getColumn(3).preferredWidth = 400 // Command + table.columnModel.getColumn(4).preferredWidth = 80 // Time + table.columnModel.getColumn(4).maxWidth = 100 + table.autoResizeMode = javax.swing.JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + + table.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val row = table.rowAtPoint(e.point) + if (row >= 0) { + val task = tableModel.getTaskAt(row) ?: return + if (task.status == "running") { + onTaskClicked?.invoke(task) + } + } + } + }) + + add(JBScrollPane(table), BorderLayout.CENTER) + + add(summaryLabel, BorderLayout.SOUTH) + + project.messageBus.connect().subscribe(TaskQueueModel.TOPIC, object : TaskQueueListener { + override fun onQueueUpdated(tasks: List, summary: QueueSummary) { + tableModel.updateTasks(tasks) + summaryLabel.text = if (tasks.isEmpty()) { + "Queue is empty" + } else { + "${summary.running} running, ${summary.waiting} waiting (${summary.total} total)" + } + } + }) + + // Ensure polling is started + TaskQueuePoller.getInstance() + } + + override fun getData(dataId: String): Any? { + if (dataId == TaskQueueDataKeys.SELECTED_TASK.name) { + val selectedRow = table.selectedRow + if (selectedRow >= 0) { + return tableModel.getTaskAt(selectedRow) + } + } + return null + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt new file mode 100644 index 0000000..72ab37c --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt @@ -0,0 +1,122 @@ +package com.block.agenttaskqueue.ui + +import com.block.agenttaskqueue.TaskQueueIcons +import com.block.agenttaskqueue.data.TaskQueuePoller +import com.block.agenttaskqueue.model.QueueSummary +import com.block.agenttaskqueue.model.QueueTask +import com.block.agenttaskqueue.model.TaskQueueListener +import com.block.agenttaskqueue.model.TaskQueueModel +import com.block.agenttaskqueue.settings.TaskQueueSettings +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.CustomStatusBarWidget +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException +import javax.swing.JComponent + +class TaskQueueStatusBarWidget(private val project: Project) : CustomStatusBarWidget { + + private var myStatusBar: StatusBar? = null + private val label = JBLabel().apply { + icon = TaskQueueIcons.TaskQueue + border = JBUI.Borders.empty(0, 4) + toolTipText = "Agent Task Queue - Click to open" + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Agent Task Queue") ?: return + if (toolWindow.isVisible) toolWindow.hide() else toolWindow.activate(null) + } + }) + } + + override fun ID(): String = "AgentTaskQueueStatusBar" + + override fun install(statusBar: StatusBar) { + myStatusBar = statusBar + project.messageBus.connect().subscribe(TaskQueueModel.TOPIC, object : TaskQueueListener { + override fun onQueueUpdated(tasks: List, summary: QueueSummary) { + updateLabel() + myStatusBar?.updateWidget(ID()) + } + }) + TaskQueuePoller.getInstance() + updateLabel() + } + + override fun dispose() {} + + override fun getComponent(): JComponent = label + + private fun updateLabel() { + val mode = TaskQueueSettings.getInstance().displayMode + val model = TaskQueueModel.getInstance() + val tasks = model.tasks + val summary = model.summary + + when (mode) { + "hidden" -> { + label.isVisible = false + return + } + "minimal" -> { + label.isVisible = true + label.text = "" + label.icon = TaskQueueIcons.TaskQueue + return + } + } + + label.isVisible = true + label.icon = TaskQueueIcons.TaskQueue + + label.text = when { + tasks.isEmpty() -> "Task Queue: empty" + else -> { + val runningTask = tasks.firstOrNull { it.status == "running" } + if (runningTask != null) { + val cmd = runningTask.displayCommand + val truncatedCmd = cmd.take(40) + if (cmd.length > 40) "..." else "" + when (mode) { + "verbose" -> { + val elapsed = formatElapsed(runningTask.updatedAt) + val waitSuffix = if (summary.waiting > 0) " (+${summary.waiting} waiting)" else "" + "Task Queue: $truncatedCmd [$elapsed]$waitSuffix" + } + else -> { + if (summary.waiting > 0) "Task Queue: $truncatedCmd (+${summary.waiting})" + else "Task Queue: $truncatedCmd" + } + } + } else { + "Task Queue: waiting (${summary.waiting})" + } + } + } + } + + private fun formatElapsed(timestamp: String?): String { + if (timestamp == null) return "0s" + return try { + val ts = timestamp.replace(" ", "T") + val parsed = LocalDateTime.parse(ts) + val instant = parsed.toInstant(ZoneOffset.UTC) + val duration = Duration.between(instant, Instant.now()) + val totalSeconds = duration.seconds + when { + totalSeconds < 60 -> "${totalSeconds}s" + totalSeconds < 3600 -> "${totalSeconds / 60}m ${totalSeconds % 60}s" + else -> "${totalSeconds / 3600}h ${(totalSeconds % 3600) / 60}m" + } + } catch (_: DateTimeParseException) { + "0s" + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidgetFactory.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidgetFactory.kt new file mode 100644 index 0000000..43433fd --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidgetFactory.kt @@ -0,0 +1,14 @@ +package com.block.agenttaskqueue.ui + +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory + +class TaskQueueStatusBarWidgetFactory : StatusBarWidgetFactory { + + override fun getId(): String = "AgentTaskQueueStatusBar" + + override fun getDisplayName(): String = "Agent Task Queue" + + override fun createWidget(project: Project): StatusBarWidget = TaskQueueStatusBarWidget(project) +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueTableModel.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueTableModel.kt new file mode 100644 index 0000000..7645e61 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueTableModel.kt @@ -0,0 +1,53 @@ +package com.block.agenttaskqueue.ui + +import com.block.agenttaskqueue.model.QueueTask +import javax.swing.table.AbstractTableModel + +class TaskQueueTableModel : AbstractTableModel() { + + private var tasks: List = emptyList() + + private val columns = arrayOf("#", "Status", "Queue", "Command", "Time") + + override fun getColumnCount(): Int = columns.size + + override fun getRowCount(): Int = tasks.size + + override fun getColumnName(column: Int): String = columns[column] + + override fun getValueAt(rowIndex: Int, columnIndex: Int): Any? { + val task = tasks.getOrNull(rowIndex) ?: return null + return when (columnIndex) { + 0 -> task.id + 1 -> task.status + 2 -> task.queueName + 3 -> task.command ?: "" + 4 -> relativeTime(task.createdAt) + else -> null + } + } + + fun getTaskAt(row: Int): QueueTask? = tasks.getOrNull(row) + + fun updateTasks(newTasks: List) { + tasks = newTasks + fireTableDataChanged() + } + + private fun relativeTime(timestamp: String?): String { + if (timestamp == null) return "" + try { + val created = java.time.LocalDateTime.parse(timestamp.replace(" ", "T")) + .atZone(java.time.ZoneOffset.UTC) + .toInstant() + val seconds = java.time.Duration.between(created, java.time.Instant.now()).seconds + return when { + seconds < 60 -> "${seconds}s ago" + seconds < 3600 -> "${seconds / 60}m ago" + else -> "${seconds / 3600}h ago" + } + } catch (e: Exception) { + return timestamp + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt new file mode 100644 index 0000000..9d60888 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt @@ -0,0 +1,77 @@ +package com.block.agenttaskqueue.ui + +import com.block.agenttaskqueue.model.QueueSummary +import com.block.agenttaskqueue.model.QueueTask +import com.block.agenttaskqueue.model.TaskQueueListener +import com.block.agenttaskqueue.model.TaskQueueModel +import com.block.agenttaskqueue.settings.TaskQueueSettings +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.content.ContentFactory +import com.intellij.ui.content.ContentManagerEvent +import com.intellij.ui.content.ContentManagerListener + +class TaskQueueToolWindowFactory : ToolWindowFactory { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val contentFactory = ContentFactory.getInstance() + val contentManager = toolWindow.contentManager + + // Queue tab (not closeable) + val queuePanel = TaskQueuePanel(project) + val queueContent = contentFactory.createContent(queuePanel, "Queue", false) + queueContent.isCloseable = false + contentManager.addContent(queueContent) + + // Track which tasks already have output tabs + val taskTabIds = mutableSetOf() + + // Remove task ID from tracking when a tab is closed so it can be reopened + contentManager.addContentManagerListener(object : ContentManagerListener { + override fun contentRemoved(event: ContentManagerEvent) { + val panel = event.content.component as? OutputPanel ?: return + taskTabIds.remove(panel.taskId) + } + }) + + fun openOutputTab(task: QueueTask) { + // If tab already exists, select it + for (content in contentManager.contents) { + val panel = content.component as? OutputPanel ?: continue + if (panel.taskId == task.id) { + contentManager.setSelectedContent(content) + return + } + } + + taskTabIds.add(task.id) + val tabTitle = task.displayCommand.take(30) + + if (task.displayCommand.length > 30) "..." else "" + val outputDir = TaskQueueSettings.getInstance().outputDir + val logPath = "$outputDir/task_${task.id}.raw.log" + + val outputPanel = OutputPanel(project, task.id, logPath) + Disposer.register(contentManager, outputPanel) + val content = contentFactory.createContent(outputPanel, tabTitle, false) + content.isCloseable = true + content.setDisposer(outputPanel) + content.description = task.displayCommand + contentManager.addContent(content) + contentManager.setSelectedContent(content) + } + + // Auto-open output tab when a task starts running + project.messageBus.connect(contentManager).subscribe(TaskQueueModel.TOPIC, object : TaskQueueListener { + override fun onQueueUpdated(tasks: List, summary: QueueSummary) { + val runningTask = tasks.firstOrNull { it.status == "running" } ?: return + if (runningTask.id in taskTabIds) return + openOutputTab(runningTask) + } + }) + + // Click on a task in the queue table to open its output tab + queuePanel.onTaskClicked = { task -> openOutputTab(task) } + } +} diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..6586711 --- /dev/null +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,75 @@ + + com.block.agenttaskqueue + Agent Task Queue + Block + 0.1.0 + + + com.intellij.modules.platform + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/intellij-plugin/src/main/resources/icons/taskQueue.svg b/intellij-plugin/src/main/resources/icons/taskQueue.svg new file mode 100644 index 0000000..7ef121b --- /dev/null +++ b/intellij-plugin/src/main/resources/icons/taskQueue.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/intellij-plugin/src/main/resources/icons/taskQueue_dark.svg b/intellij-plugin/src/main/resources/icons/taskQueue_dark.svg new file mode 100644 index 0000000..063cb80 --- /dev/null +++ b/intellij-plugin/src/main/resources/icons/taskQueue_dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/task_queue.py b/task_queue.py index f5003cb..2362b1c 100644 --- a/task_queue.py +++ b/task_queue.py @@ -21,6 +21,8 @@ from fastmcp import FastMCP from fastmcp.server.dependencies import get_context +from fastmcp.tools.tool import ToolResult +from mcp.types import TextContent # Import shared queue infrastructure from queue_core import ( @@ -193,13 +195,16 @@ def cleanup_queue(conn, queue_name: str): # --- Output File Management --- def cleanup_output_files(): - """Remove oldest output files if over MAX_OUTPUT_FILES limit.""" + """Remove oldest output files if over limit. Covers both .log and .raw.log files.""" if not OUTPUT_DIR.exists(): return - files = sorted(OUTPUT_DIR.glob("task_*.log"), key=lambda f: f.stat().st_mtime) - if len(files) > MAX_OUTPUT_FILES: - for old_file in files[: len(files) - MAX_OUTPUT_FILES]: + # Group files by task ID so both .log and .raw.log are cleaned together + files = sorted(OUTPUT_DIR.glob("task_*"), key=lambda f: f.stat().st_mtime) + # Each task produces up to 2 files (.log + .raw.log), so scale the limit + max_files = MAX_OUTPUT_FILES * 2 + if len(files) > max_files: + for old_file in files[: len(files) - max_files]: try: old_file.unlink() except OSError: @@ -212,7 +217,7 @@ def clear_output_files() -> int: return 0 count = 0 - for f in OUTPUT_DIR.glob("task_*.log"): + for f in OUTPUT_DIR.glob("task_*"): try: f.unlink() count += 1 @@ -372,17 +377,31 @@ async def release_lock(task_id: int): # --- The Tool --- -@mcp.tool() +@mcp.tool( + title="Run Queued Task", + annotations={ + "destructiveHint": True, + "openWorldHint": False, + "idempotentHint": False, + }, +) async def run_task( command: str, working_directory: str, queue_name: str = "global", timeout_seconds: int = 1200, env_vars: str = "", -) -> str: +): """ Execute a command through the task queue for sequential processing. + IMPORTANT: Before calling this tool, tell the user the exact command you are + about to run (e.g., "Running `./gradlew :app:compileDebugKotlin`"). + This provides visibility since the tool execution may take a while. + + When a command fails, analyze the output tail to identify the root cause and + show the user the specific error with the responsible file/line if available. + YOU MUST USE THIS TOOL instead of running shell commands directly when the command involves ANY of the following: @@ -450,7 +469,14 @@ async def run_task( stdout_count = 0 stderr_count = 0 - # Create output file early and stream directly to it + # Two output files are written per task: + # task_.log — formatted log with metadata headers, section markers (--- STDOUT ---, + # --- STDERR ---, --- SUMMARY ---), and exit code. Written by all MCP + # server versions. Used by the IntelliJ plugin notifier to read exit + # codes, and by "View Output" to open full logs. + # task_.raw.log — raw stdout+stderr only, no markers or metadata. Added in MCP server + # v0.4.0 (not present in v0.3.x and earlier). Used by the IntelliJ + # plugin OutputStreamer for clean tailing in output tabs. OUTPUT_DIR.mkdir(parents=True, exist_ok=True) output_file = OUTPUT_DIR / f"task_{task_id}.log" @@ -473,15 +499,17 @@ async def run_task( "UPDATE queue SET child_pid = ? WHERE id = ?", (proc.pid, task_id) ) - # Open file for streaming output - write header first - with open(output_file, "w") as f: + # Open files for streaming output - formatted log + raw log for plugin tailing + raw_output_file = OUTPUT_DIR / f"task_{task_id}.raw.log" + with open(output_file, "w") as f, open(raw_output_file, "w") as raw_f: + # Header to formatted log only f.write(f"COMMAND: {command}\n") f.write(f"WORKING DIR: {working_directory}\n") f.write(f"STARTED: {datetime.now().isoformat()}\n") f.write("\n--- STDOUT ---\n") async def stream_to_file(stream, tail_buffer: deque, label: str): - """Stream output directly to file, keeping only tail in memory.""" + """Stream output directly to both files, keeping only tail in memory.""" nonlocal stdout_count, stderr_count while True: line = await stream.readline() @@ -489,7 +517,9 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): break decoded = line.decode().rstrip() f.write(decoded + "\n") - f.flush() # Ensure immediate write to disk + f.flush() + raw_f.write(decoded + "\n") + raw_f.flush() tail_buffer.append(decoded) if label == "stdout": stdout_count += 1 @@ -510,7 +540,7 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): await proc.wait() duration = time.time() - start - # Append summary to file + # Append summary to formatted log only f.write("\n--- SUMMARY ---\n") f.write(f"EXIT CODE: {proc.returncode}\n") f.write(f"DURATION: {duration:.1f}s\n") @@ -536,7 +566,18 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): tail = list(stderr_tail) if stderr_tail else list(stdout_tail) tail_text = "\n".join(tail) if tail else "(no output)" - return f"TIMEOUT killed after {timeout_seconds}s output={output_file}\n{tail_text}" + text = f"TIMEOUT killed after {timeout_seconds}s command={command} output={output_file}\n{tail_text}" + return ToolResult( + content=[TextContent(type="text", text=text)], + structured_content={"result": { + "status": "timeout", + "exit_code": None, + "duration_seconds": timeout_seconds, + "command": command, + "output_file": str(output_file), + "tail": tail_text, + }}, + ) # File is now closed, log metrics mem_after = get_memory_mb() @@ -556,12 +597,34 @@ async def stream_to_file(stream, tail_buffer: deque, label: str): # Return concise summary for agents if proc.returncode == 0: - return f"SUCCESS exit=0 {duration:.1f}s output={output_file}" + text = f"SUCCESS exit=0 {duration:.1f}s command={command} output={output_file}" + return ToolResult( + content=[TextContent(type="text", text=text)], + structured_content={"result": { + "status": "success", + "exit_code": 0, + "duration_seconds": round(duration, 1), + "command": command, + "output_file": str(output_file), + "tail": None, + }}, + ) else: # On failure, include tail of output for context tail = list(stderr_tail) if stderr_tail else list(stdout_tail) tail_text = "\n".join(tail) if tail else "(no output)" - return f"FAILED exit={proc.returncode} {duration:.1f}s output={output_file}\n{tail_text}" + text = f"FAILED exit={proc.returncode} {duration:.1f}s command={command} output={output_file}\n{tail_text}" + return ToolResult( + content=[TextContent(type="text", text=text)], + structured_content={"result": { + "status": "failed", + "exit_code": proc.returncode, + "duration_seconds": round(duration, 1), + "command": command, + "output_file": str(output_file), + "tail": tail_text, + }}, + ) except asyncio.CancelledError: # Client disconnected while task was running - kill the subprocess diff --git a/tests/test_queue.py b/tests/test_queue.py index c170249..115ecb9 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -505,9 +505,15 @@ async def test_output_file_rotation(): }, ) - # Should only have MAX_OUTPUT_FILES files - files = list(OUTPUT_DIR.glob("task_*.log")) - assert len(files) <= MAX_OUTPUT_FILES + # Should only have MAX_OUTPUT_FILES tasks worth of files + # Each task produces 2 files (.log + .raw.log), glob("task_*.log") matches both + all_files = list(OUTPUT_DIR.glob("task_*.log")) + assert len(all_files) <= MAX_OUTPUT_FILES * 2 + # Verify .raw.log files are also cleaned (not just .log) + log_files = [f for f in all_files if f.name.endswith(".log") and not f.name.endswith(".raw.log")] + raw_files = [f for f in all_files if f.name.endswith(".raw.log")] + assert len(log_files) <= MAX_OUTPUT_FILES + assert len(raw_files) <= MAX_OUTPUT_FILES @pytest.mark.asyncio @@ -538,10 +544,11 @@ async def test_oldest_logs_deleted_first(): if match: task_ids.append(int(match.group(1))) - # Get remaining files - remaining_files = list(OUTPUT_DIR.glob("task_*.log")) + # Get remaining files — filter to .log only (exclude .raw.log) for task ID extraction + all_remaining = list(OUTPUT_DIR.glob("task_*.log")) + remaining_log_files = [f for f in all_remaining if not f.name.endswith(".raw.log")] remaining_ids = [] - for f in remaining_files: + for f in remaining_log_files: import re match = re.search(r"task_(\d+)\.log", f.name) @@ -551,6 +558,10 @@ async def test_oldest_logs_deleted_first(): # The first 3 task IDs should be gone (oldest deleted) for old_id in task_ids[:3]: assert old_id not in remaining_ids, f"Old task {old_id} should have been deleted" + # Verify the .raw.log companion file is also gone + assert not (OUTPUT_DIR / f"task_{old_id}.raw.log").exists(), ( + f"Raw log for old task {old_id} should also have been deleted" + ) # The last MAX_OUTPUT_FILES task IDs should still exist for new_id in task_ids[-MAX_OUTPUT_FILES:]: