Skip to content

Commit 8bfc787

Browse files
authored
Merge pull request #18 from block/mmckenna/intellij-plugin
Add IntelliJ plugin for agent-task-queue visibility
2 parents 5117389 + 901998c commit 8bfc787

38 files changed

Lines changed: 2122 additions & 26 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ dist/
3333
# Local notes
3434
thoughts/
3535
*.lock
36+
37+
# IntelliJ Plugin
38+
intellij-plugin/build/
39+
intellij-plugin/.gradle/

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,10 @@ Pass options via the `args` property in your MCP config:
315315

316316
Run `uvx agent-task-queue@latest --help` to see all options.
317317

318+
## IntelliJ Plugin
319+
320+
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.
321+
318322
## Architecture
319323

320324
```mermaid
@@ -344,7 +348,9 @@ The queue state is stored in SQLite at `/tmp/agent-task-queue/queue.db`:
344348
| `id` | INTEGER | Auto-incrementing primary key |
345349
| `queue_name` | TEXT | Queue identifier (e.g., "global", "android") |
346350
| `status` | TEXT | Task state: "waiting" or "running" |
351+
| `command` | TEXT | Shell command being executed |
347352
| `pid` | INTEGER | MCP server process ID (for liveness check) |
353+
| `server_id` | TEXT | Server instance UUID (for orphan detection across PID reuse) |
348354
| `child_pid` | INTEGER | Subprocess ID (for orphan cleanup) |
349355
| `created_at` | TIMESTAMP | When task was queued |
350356
| `updated_at` | TIMESTAMP | Last status change |
@@ -383,23 +389,29 @@ To reduce token usage, full command output is written to files instead of return
383389

384390
```
385391
/tmp/agent-task-queue/output/
386-
├── task_1.log
392+
├── task_1.log # Formatted log with metadata and section markers
393+
├── task_1.raw.log # Raw stdout+stderr only (for plugin streaming)
387394
├── task_2.log
395+
├── task_2.raw.log
388396
└── ...
389397
```
390398

399+
Each task produces two output files:
400+
- **`task_<id>.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.
401+
- **`task_<id>.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.
402+
391403
**On success**, the tool returns a single line:
392404
```
393-
SUCCESS exit=0 31.2s output=/tmp/agent-task-queue/output/task_8.log
405+
SUCCESS exit=0 31.2s command=./gradlew build output=/tmp/agent-task-queue/output/task_8.log
394406
```
395407

396408
**On failure**, the last 50 lines of output are included:
397409
```
398-
FAILED exit=1 12.5s output=/tmp/agent-task-queue/output/task_9.log
410+
FAILED exit=1 12.5s command=./gradlew build output=/tmp/agent-task-queue/output/task_9.log
399411
[error output here]
400412
```
401413

402-
**Automatic cleanup**: Old files are deleted when count exceeds 50 (configurable via `MAX_OUTPUT_FILES`).
414+
**Automatic cleanup**: Old files are deleted when count exceeds 50 tasks (configurable via `--max-output-files`).
403415

404416
**Manual cleanup**: Use the `clear_task_logs` tool to delete all output files.
405417

intellij-plugin/README.md

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# IntelliJ Plugin
2+
3+
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.
4+
5+
## Features
6+
7+
### Status Bar Widget
8+
9+
Displays current queue state in the IDE status bar with four configurable display modes:
10+
11+
| Mode | Shows |
12+
|------|-------|
13+
| **Hidden** | Nothing — widget invisible |
14+
| **Minimal** | Icon only |
15+
| **Default** | `Task Queue: ./gradlew build (+2)` |
16+
| **Verbose** | `Task Queue: ./gradlew build [2m 13s] (+2 waiting)` |
17+
18+
Click the widget to open the tool window. Configure the display mode in **Settings > Tools > Agent Task Queue**.
19+
20+
### Tool Window
21+
22+
- **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.
23+
- **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.
24+
25+
### Notifications
26+
27+
Balloon notifications for queue events (can be disabled in settings):
28+
29+
| Event | Type | Content |
30+
|-------|------|---------|
31+
| Task starts running | Info balloon | "Running: `./gradlew build`" |
32+
| Task finishes (exit 0) | Info balloon | "Finished: `./gradlew build`" |
33+
| Task fails (exit != 0) | Error balloon (sticky) | "Failed: `./gradlew build`" + View Output action |
34+
35+
Failure detection works by reading the `EXIT CODE` from the formatted task log (`task_<id>.log`) after it disappears from the queue.
36+
37+
## Architecture
38+
39+
### How It Reads Data
40+
41+
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.
42+
43+
```
44+
TaskQueuePoller (1-3s interval)
45+
└── TaskQueueDatabase.fetchAllTasks()
46+
└── SELECT * FROM queue ORDER BY queue_name, id
47+
└── jdbc:sqlite:/tmp/agent-task-queue/queue.db
48+
```
49+
50+
### Polling Strategy
51+
52+
Two independent polling loops, each active only when needed:
53+
54+
**Database poller** (`TaskQueuePoller`) — Polls the SQLite queue database:
55+
- 1s interval when tasks exist (active)
56+
- 3s interval when queue is empty (idle)
57+
- Supports manual refresh via a conflated coroutine channel
58+
- Detects stale tasks by checking if the server PID is still alive (`kill -0`), and removes them from the DB
59+
60+
**Output file tailer** (`OutputStreamer`) — Tails the running task's output file:
61+
- Only active while a task is running (no coroutine exists otherwise)
62+
- 50ms interval when new data was just read (active streaming)
63+
- 200ms interval when no new data (waiting for output)
64+
- Uses `RandomAccessFile` with byte offset tracking to read only new content
65+
- Prefers `task_<id>.raw.log` (MCP server v0.4.0+) for clean output with no filtering
66+
- Falls back to `task_<id>.log` with header skipping and marker filtering for MCP server v0.3.x and earlier
67+
68+
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.
69+
70+
### Data Flow
71+
72+
```
73+
TaskQueuePoller ──poll()──> TaskQueueDatabase ──SQL──> SQLite DB
74+
75+
└── TaskQueueModel.update(tasks)
76+
77+
└── messageBus.syncPublisher(TOPIC)
78+
79+
├── TaskQueueStatusBarWidget.updateLabel()
80+
├── TaskQueuePanel (table + summary)
81+
├── OutputPanel ──start/stopTailing──> OutputStreamer
82+
└── TaskQueueNotifier (balloon notifications)
83+
```
84+
85+
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`.
86+
87+
### Process Cancellation
88+
89+
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 -<pgid>` cleanly terminates the entire process tree.
90+
91+
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.
92+
93+
## Database Schema
94+
95+
The plugin reads from the `queue` table:
96+
97+
| Column | Type | Description |
98+
|--------|------|-------------|
99+
| `id` | INTEGER | Auto-incrementing primary key |
100+
| `queue_name` | TEXT | Queue identifier (e.g., "global") |
101+
| `status` | TEXT | "waiting" or "running" |
102+
| `command` | TEXT | Shell command being executed |
103+
| `pid` | INTEGER | MCP server process ID |
104+
| `child_pid` | INTEGER | Subprocess group ID (used for cancellation) |
105+
| `created_at` | TIMESTAMP | When task was queued |
106+
| `updated_at` | TIMESTAMP | Last status change |
107+
108+
Output logs are at `<data_dir>/output/task_<id>.log` (formatted) and `<data_dir>/output/task_<id>.raw.log` (raw output, MCP server v0.4.0+).
109+
110+
## Building
111+
112+
```bash
113+
cd intellij-plugin
114+
./gradlew buildPlugin
115+
```
116+
117+
The built plugin ZIP is at `build/distributions/`.
118+
119+
### Requirements
120+
121+
- JDK 21+
122+
- IntelliJ IDEA 2024.2+ (build 242-252.*)
123+
124+
### Dependencies
125+
126+
- `org.xerial:sqlite-jdbc:3.47.2.0` — SQLite JDBC driver
127+
- Kotlin coroutines — bundled with IntelliJ Platform (do NOT add as a dependency)
128+
129+
## Settings
130+
131+
Persisted in `AgentTaskQueueSettings.xml`:
132+
133+
| Setting | Default | Description |
134+
|---------|---------|-------------|
135+
| `dataDir` | `$TASK_QUEUE_DATA_DIR` or `/tmp/agent-task-queue` | Path to agent-task-queue data directory |
136+
| `displayMode` | `default` | Status bar display: `hidden`, `minimal`, `default`, `verbose` |
137+
| `notificationsEnabled` | `true` | Show balloon notifications for queue events |
138+
139+
## Project Structure
140+
141+
```
142+
src/main/kotlin/com/block/agenttaskqueue/
143+
├── TaskQueueIcons.kt # Icon loading
144+
├── actions/
145+
│ ├── CancelTaskAction.kt # Cancel selected task
146+
│ ├── ClearQueueAction.kt # Clear all tasks
147+
│ ├── OpenOutputLogAction.kt # Open log file in editor
148+
│ ├── OpenSettingsAction.kt # Open settings page
149+
│ ├── RefreshQueueAction.kt # Manual refresh
150+
│ └── TaskQueueDataKeys.kt # DataKey for selected task
151+
├── data/
152+
│ ├── OutputStreamer.kt # Coroutine file tailer
153+
│ ├── TaskCanceller.kt # Process group termination
154+
│ ├── TaskQueueDatabase.kt # SQLite JDBC access
155+
│ ├── TaskQueueNotifier.kt # Balloon notifications
156+
│ └── TaskQueuePoller.kt # Background DB polling
157+
├── model/
158+
│ ├── QueueSummary.kt # Aggregate counts
159+
│ ├── QueueTask.kt # Task data class
160+
│ └── TaskQueueModel.kt # Shared state + message bus topic
161+
├── settings/
162+
│ ├── TaskQueueConfigurable.kt # Settings UI
163+
│ └── TaskQueueSettings.kt # Persistent state
164+
└── ui/
165+
├── OutputPanel.kt # Live console output tab
166+
├── TaskQueuePanel.kt # Queue table tab
167+
├── TaskQueueStatusBarWidget.kt
168+
├── TaskQueueStatusBarWidgetFactory.kt
169+
├── TaskQueueTableModel.kt # Table data model
170+
└── TaskQueueToolWindowFactory.kt
171+
```

intellij-plugin/build.gradle.kts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
plugins {
2+
id("java")
3+
id("org.jetbrains.kotlin.jvm") version "1.9.25"
4+
id("org.jetbrains.intellij.platform") version "2.2.1"
5+
}
6+
7+
group = providers.gradleProperty("pluginGroup").get()
8+
version = providers.gradleProperty("pluginVersion").get()
9+
10+
repositories {
11+
mavenCentral()
12+
intellijPlatform {
13+
defaultRepositories()
14+
}
15+
}
16+
17+
dependencies {
18+
intellijPlatform {
19+
intellijIdeaCommunity(providers.gradleProperty("platformVersion").get())
20+
}
21+
22+
implementation("org.xerial:sqlite-jdbc:3.47.2.0")
23+
}
24+
25+
intellijPlatform {
26+
pluginConfiguration {
27+
name = providers.gradleProperty("pluginName")
28+
version = providers.gradleProperty("pluginVersion")
29+
ideaVersion {
30+
sinceBuild = providers.gradleProperty("pluginSinceBuild")
31+
untilBuild = providers.gradleProperty("pluginUntilBuild")
32+
}
33+
}
34+
}
35+
36+
java {
37+
sourceCompatibility = JavaVersion.VERSION_21
38+
targetCompatibility = JavaVersion.VERSION_21
39+
}
40+
41+
tasks {
42+
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
43+
kotlinOptions.jvmTarget = "21"
44+
}
45+
}

intellij-plugin/gradle.properties

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
pluginGroup = com.block.agenttaskqueue
2+
pluginName = Agent Task Queue
3+
pluginVersion = 0.1.0
4+
pluginSinceBuild = 242
5+
pluginUntilBuild = 252.*
6+
7+
platformType = IC
8+
platformVersion = 2024.2
9+
10+
org.gradle.jvmargs = -Xmx2g
11+
kotlin.stdlib.default.dependency = false
42.6 KB
Binary file not shown.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
distributionBase=GRADLE_USER_HOME
2+
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
4+
networkTimeout=10000
5+
validateDistributionUrl=true
6+
zipStoreBase=GRADLE_USER_HOME
7+
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)