From 418690d381b12d8ee8bded38570c370ebd69424c Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Fri, 6 Feb 2026 15:28:21 -0500 Subject: [PATCH 01/10] Add IntelliJ plugin for agent-task-queue visibility Status bar widget and tool window for monitoring the task queue directly from the IDE. Reads the SQLite database used by the MCP server, with adaptive polling (1s active, 3s idle). Supports cancelling tasks (SIGTERM/SIGKILL process group), clearing the queue, and opening output logs in the editor. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 + intellij-plugin/build.gradle.kts | 45 ++++ intellij-plugin/gradle.properties | 11 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + intellij-plugin/gradlew | 252 ++++++++++++++++++ intellij-plugin/gradlew.bat | 94 +++++++ intellij-plugin/settings.gradle.kts | 1 + .../block/agenttaskqueue/TaskQueueIcons.kt | 9 + .../actions/CancelTaskAction.kt | 30 +++ .../actions/ClearQueueAction.kt | 34 +++ .../actions/OpenOutputLogAction.kt | 36 +++ .../actions/RefreshQueueAction.kt | 12 + .../actions/TaskQueueDataKeys.kt | 8 + .../agenttaskqueue/data/TaskCanceller.kt | 75 ++++++ .../agenttaskqueue/data/TaskQueueDatabase.kt | 82 ++++++ .../agenttaskqueue/data/TaskQueuePoller.kt | 65 +++++ .../agenttaskqueue/model/QueueSummary.kt | 19 ++ .../block/agenttaskqueue/model/QueueTask.kt | 12 + .../agenttaskqueue/model/TaskQueueModel.kt | 38 +++ .../settings/TaskQueueConfigurable.kt | 48 ++++ .../settings/TaskQueueSettings.kt | 41 +++ .../block/agenttaskqueue/ui/TaskQueuePanel.kt | 71 +++++ .../ui/TaskQueueStatusBarWidget.kt | 71 +++++ .../ui/TaskQueueStatusBarWidgetFactory.kt | 14 + .../agenttaskqueue/ui/TaskQueueTableModel.kt | 53 ++++ .../ui/TaskQueueToolWindowFactory.kt | 15 ++ .../src/main/resources/META-INF/plugin.xml | 64 +++++ .../src/main/resources/icons/taskQueue.svg | 13 + .../main/resources/icons/taskQueue_dark.svg | 13 + 30 files changed, 1237 insertions(+) create mode 100644 intellij-plugin/build.gradle.kts create mode 100644 intellij-plugin/gradle.properties create mode 100644 intellij-plugin/gradle/wrapper/gradle-wrapper.jar create mode 100644 intellij-plugin/gradle/wrapper/gradle-wrapper.properties create mode 100755 intellij-plugin/gradlew create mode 100644 intellij-plugin/gradlew.bat create mode 100644 intellij-plugin/settings.gradle.kts create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/TaskQueueIcons.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/CancelTaskAction.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/ClearQueueAction.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenOutputLogAction.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/RefreshQueueAction.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/TaskQueueDataKeys.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueDatabase.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueSummary.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueTask.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/TaskQueueModel.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueSettings.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidgetFactory.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueTableModel.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt create mode 100644 intellij-plugin/src/main/resources/META-INF/plugin.xml create mode 100644 intellij-plugin/src/main/resources/icons/taskQueue.svg create mode 100644 intellij-plugin/src/main/resources/icons/taskQueue_dark.svg 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/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 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 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/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/TaskCanceller.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt new file mode 100644 index 0000000..6f62b1e --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt @@ -0,0 +1,75 @@ +package com.block.agenttaskqueue.data + +import com.block.agenttaskqueue.model.QueueTask +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) { + 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) { + 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/TaskQueuePoller.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt new file mode 100644 index 0000000..9f58973 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt @@ -0,0 +1,65 @@ +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 tasks = TaskQueueDatabase.getInstance().fetchAllTasks() + if (tasks != previousTasks) { + previousTasks = tasks + TaskQueueModel.getInstance().update(tasks) + } + } catch (e: Exception) { + LOG.warn("Failed to poll task queue", e) + } + } + + 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..f21c90e --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueTask.kt @@ -0,0 +1,12 @@ +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?, +) 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..3c006ca --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/TaskQueueModel.kt @@ -0,0 +1,38 @@ +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) + 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..5c048da --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt @@ -0,0 +1,48 @@ +package com.block.agenttaskqueue.settings + +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.FormBuilder +import javax.swing.JComponent +import javax.swing.JPanel + +class TaskQueueConfigurable : Configurable { + + private var panel: JPanel? = null + private var dataDirField: TextFieldWithBrowseButton? = null + + override fun getDisplayName(): String = "Agent Task Queue" + + override fun createComponent(): JComponent { + dataDirField = TextFieldWithBrowseButton().apply { + text = TaskQueueSettings.getInstance().dataDir + addBrowseFolderListener("Select Data Directory", "Choose the agent-task-queue data directory", null, + com.intellij.openapi.fileChooser.FileChooserDescriptorFactory.createSingleFolderDescriptor()) + } + + panel = FormBuilder.createFormBuilder() + .addLabeledComponent(JBLabel("Data directory:"), dataDirField!!, 1, false) + .addComponentFillVertically(JPanel(), 0) + .panel + + return panel!! + } + + override fun isModified(): Boolean { + return dataDirField?.text != TaskQueueSettings.getInstance().dataDir + } + + override fun apply() { + TaskQueueSettings.getInstance().dataDir = dataDirField?.text ?: return + } + + override fun reset() { + dataDirField?.text = TaskQueueSettings.getInstance().dataDir + } + + override fun disposeUIResources() { + panel = null + dataDirField = 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..30b448e --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueSettings.kt @@ -0,0 +1,41 @@ +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", + ) + + 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 + } + + 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/TaskQueuePanel.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt new file mode 100644 index 0000000..541d540 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt @@ -0,0 +1,71 @@ +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 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") + + 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 + + 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..e5a3631 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt @@ -0,0 +1,71 @@ +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.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 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) { + ToolWindowManager.getInstance(project).getToolWindow("Agent Task Queue")?.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 model = TaskQueueModel.getInstance() + val tasks = model.tasks + val summary = model.summary + + label.text = when { + tasks.isEmpty() -> "Task Queue: empty" + else -> { + val runningTask = tasks.firstOrNull { it.status == "running" } + if (runningTask != null) { + val cmd = runningTask.command ?: "unknown" + val truncatedCmd = cmd.take(40) + if (cmd.length > 40) "..." else "" + if (summary.waiting > 0) "Task Queue: $truncatedCmd (+${summary.waiting})" + else "Task Queue: $truncatedCmd" + } else { + "Task Queue: waiting (${summary.waiting})" + } + } + } + } +} 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..8884ca9 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt @@ -0,0 +1,15 @@ +package com.block.agenttaskqueue.ui + +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.content.ContentFactory + +class TaskQueueToolWindowFactory : ToolWindowFactory { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val panel = TaskQueuePanel(project) + val content = ContentFactory.getInstance().createContent(panel, "", false) + toolWindow.contentManager.addContent(content) + } +} 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..6a0239c --- /dev/null +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,64 @@ + + 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 @@ + + + + + + + + + + + + + From 2ecd9c059c9e345bafc73f4382a713427a6e2a56 Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Fri, 6 Feb 2026 15:51:29 -0500 Subject: [PATCH 02/10] Add plugin QoL: display modes, notifications, streaming output, README - Configurable status bar display (hidden/minimal/default/verbose) - Balloon notifications for task start/finish/failure with exit code detection - Live streaming output tab in tool window using adaptive-rate file tailing - Plugin README documenting architecture, polling strategy, and design decisions - Link to plugin README from top-level README Co-Authored-By: Claude Opus 4.6 --- README.md | 4 + intellij-plugin/README.md | 167 ++++++++++++++++++ .../agenttaskqueue/data/OutputStreamer.kt | 73 ++++++++ .../agenttaskqueue/data/TaskQueueNotifier.kt | 127 +++++++++++++ .../settings/TaskQueueConfigurable.kt | 37 +++- .../settings/TaskQueueSettings.kt | 14 ++ .../block/agenttaskqueue/ui/OutputPanel.kt | 85 +++++++++ .../ui/TaskQueueStatusBarWidget.kt | 54 +++++- .../ui/TaskQueueToolWindowFactory.kt | 12 +- .../src/main/resources/META-INF/plugin.xml | 4 + 10 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 intellij-plugin/README.md create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueueNotifier.kt create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt diff --git a/README.md b/README.md index e68a82c..cb39153 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 diff --git a/intellij-plugin/README.md b/intellij-plugin/README.md new file mode 100644 index 0000000..4f68732 --- /dev/null +++ b/intellij-plugin/README.md @@ -0,0 +1,167 @@ +# 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 + +Two tabs: + +- **Queue** — Table of all tasks with ID, status, queue name, command, and relative time. Toolbar actions for refresh, cancel, clear, and view output. +- **Output** — Live streaming console view of the currently running task's output. Automatically switches when a new task starts running. + +### 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 task's output 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 + +**Output file tailer** (`OutputStreamer`) — Tails the running task's log 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 + +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. + +## 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`. + +## 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 +│ ├── 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/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..cc58b00 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt @@ -0,0 +1,73 @@ +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 + +class OutputStreamer( + private val scope: CoroutineScope, + private val onContent: (String) -> Unit, + private val onClear: () -> Unit, + private val onNoTask: () -> 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 + } + + private var currentTaskId: Int? = null + private var fileOffset: Long = 0 + private var tailJob: Job? = null + + fun startTailing(taskId: Int, logFilePath: String) { + if (taskId == currentTaskId) return + stopTailing() + currentTaskId = taskId + fileOffset = 0 + onClear() + + tailJob = scope.launch(Dispatchers.IO) { + val file = File(logFilePath) + while (isActive) { + var hadNewData = false + 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 = String(bytes, Charsets.UTF_8) + onContent(text) + hadNewData = true + } + } + } catch (e: Exception) { + LOG.debug("Error tailing log file: $logFilePath", e) + } + // Poll faster when output is actively flowing, slower when idle + delay(if (hadNewData) ACTIVE_POLL_MS else IDLE_POLL_MS) + } + } + } + + fun stopTailing() { + tailJob?.cancel() + tailJob = null + currentTaskId = null + fileOffset = 0 + } + + fun showNoTask() { + stopTailing() + onNoTask() + } +} 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/settings/TaskQueueConfigurable.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt index 5c048da..7cf0b19 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt @@ -1,9 +1,12 @@ package com.block.agenttaskqueue.settings 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 @@ -11,18 +14,33 @@ 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 = TaskQueueSettings.getInstance().dataDir + 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 @@ -30,19 +48,30 @@ class TaskQueueConfigurable : Configurable { } override fun isModified(): Boolean { - return dataDirField?.text != TaskQueueSettings.getInstance().dataDir + val settings = TaskQueueSettings.getInstance() + return dataDirField?.text != settings.dataDir + || displayModes.getOrNull(displayModeCombo?.selectedIndex ?: -1) != settings.displayMode + || notificationsCheckbox?.isSelected != settings.notificationsEnabled } override fun apply() { - TaskQueueSettings.getInstance().dataDir = dataDirField?.text ?: return + val settings = TaskQueueSettings.getInstance() + settings.dataDir = dataDirField?.text ?: return + settings.displayMode = displayModes.getOrNull(displayModeCombo?.selectedIndex ?: -1) ?: "default" + settings.notificationsEnabled = notificationsCheckbox?.isSelected ?: true } override fun reset() { - dataDirField?.text = TaskQueueSettings.getInstance().dataDir + 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 index 30b448e..ab3c814 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueSettings.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueSettings.kt @@ -12,6 +12,8 @@ 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() @@ -28,6 +30,18 @@ class TaskQueueSettings : PersistentStateComponent { 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" 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..c19591c --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt @@ -0,0 +1,85 @@ +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.block.agenttaskqueue.settings.TaskQueueSettings +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.ui.ConsoleView +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(project: Project) : JPanel(BorderLayout()), Disposable { + + private val consoleView: ConsoleView = + TextConsoleBuilderFactory.getInstance().createBuilder(project).console + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var currentRunningTaskId: Int? = null + + private val streamer = OutputStreamer( + scope = scope, + onContent = { text -> + ApplicationManager.getApplication().invokeLater { + consoleView.print(text, ConsoleViewContentType.NORMAL_OUTPUT) + } + }, + onClear = { + ApplicationManager.getApplication().invokeLater { + consoleView.clear() + } + }, + onNoTask = { + ApplicationManager.getApplication().invokeLater { + consoleView.clear() + consoleView.print("No task running\n", ConsoleViewContentType.SYSTEM_OUTPUT) + } + } + ) + + init { + Disposer.register(project, this) + add(consoleView.component, BorderLayout.CENTER) + + project.messageBus.connect(this).subscribe(TaskQueueModel.TOPIC, object : TaskQueueListener { + override fun onQueueUpdated(tasks: List, summary: QueueSummary) { + val runningTask = tasks.firstOrNull { it.status == "running" } + handleRunningTask(runningTask) + } + }) + + // Initial state + val runningTask = TaskQueueModel.getInstance().tasks.firstOrNull { it.status == "running" } + handleRunningTask(runningTask) + } + + private fun handleRunningTask(task: QueueTask?) { + if (task == null) { + currentRunningTaskId = null + streamer.showNoTask() + return + } + + if (task.id != currentRunningTaskId) { + currentRunningTaskId = task.id + val outputDir = TaskQueueSettings.getInstance().outputDir + val logPath = "$outputDir/task_${task.id}.log" + streamer.startTailing(task.id, logPath) + } + } + + 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/TaskQueueStatusBarWidget.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt index e5a3631..55e6429 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt @@ -6,6 +6,7 @@ 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 @@ -14,6 +15,11 @@ 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 { @@ -49,10 +55,27 @@ class TaskQueueStatusBarWidget(private val project: Project) : CustomStatusBarWi 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 -> { @@ -60,12 +83,39 @@ class TaskQueueStatusBarWidget(private val project: Project) : CustomStatusBarWi if (runningTask != null) { val cmd = runningTask.command ?: "unknown" val truncatedCmd = cmd.take(40) + if (cmd.length > 40) "..." else "" - if (summary.waiting > 0) "Task Queue: $truncatedCmd (+${summary.waiting})" - else "Task Queue: $truncatedCmd" + 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/TaskQueueToolWindowFactory.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt index 8884ca9..f89cbf0 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt @@ -8,8 +8,14 @@ import com.intellij.ui.content.ContentFactory class TaskQueueToolWindowFactory : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val panel = TaskQueuePanel(project) - val content = ContentFactory.getInstance().createContent(panel, "", false) - toolWindow.contentManager.addContent(content) + val contentFactory = ContentFactory.getInstance() + + val queuePanel = TaskQueuePanel(project) + val queueContent = contentFactory.createContent(queuePanel, "Queue", false) + toolWindow.contentManager.addContent(queueContent) + + val outputPanel = OutputPanel(project) + val outputContent = contentFactory.createContent(outputPanel, "Output", false) + toolWindow.contentManager.addContent(outputContent) } } diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin/src/main/resources/META-INF/plugin.xml index 6a0239c..b235122 100644 --- a/intellij-plugin/src/main/resources/META-INF/plugin.xml +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -17,6 +17,10 @@ + + + + Date: Sat, 7 Feb 2026 09:39:40 -0500 Subject: [PATCH 03/10] Improve output streaming, per-task tabs, settings gear, status bar toggle - Stream only command output (skip log headers, filter markers/summary) - Create a new output tab per task, titled with command (env vars stripped) - Output persists after task finishes; tabs are user-closeable - Strip env var prefixes from status bar display via QueueTask.displayCommand - Add settings gear icon to tool window toolbar - Status bar click toggles tool window open/closed - Settings apply triggers immediate UI re-render via notifyListeners() Co-Authored-By: Claude Opus 4.6 --- .../actions/OpenSettingsAction.kt | 13 +++ .../agenttaskqueue/data/OutputStreamer.kt | 82 +++++++++++++++---- .../block/agenttaskqueue/model/QueueTask.kt | 6 +- .../agenttaskqueue/model/TaskQueueModel.kt | 4 + .../settings/TaskQueueConfigurable.kt | 2 + .../block/agenttaskqueue/ui/OutputPanel.kt | 46 +++-------- .../ui/TaskQueueStatusBarWidget.kt | 5 +- .../ui/TaskQueueToolWindowFactory.kt | 38 ++++++++- .../src/main/resources/META-INF/plugin.xml | 6 ++ 9 files changed, 148 insertions(+), 54 deletions(-) create mode 100644 intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/actions/OpenSettingsAction.kt 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/data/OutputStreamer.kt b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt index cc58b00..2b489f7 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt @@ -14,24 +14,28 @@ class OutputStreamer( private val scope: CoroutineScope, private val onContent: (String) -> Unit, private val onClear: () -> Unit, - private val onNoTask: () -> 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 + private const val STDOUT_MARKER = "--- STDOUT ---\n" } private var currentTaskId: Int? = null + private var currentLogPath: String? = null private var fileOffset: Long = 0 private var tailJob: Job? = null + private var headerSkipped = false fun startTailing(taskId: Int, logFilePath: String) { if (taskId == currentTaskId) return stopTailing() currentTaskId = taskId + currentLogPath = logFilePath fileOffset = 0 + headerSkipped = false onClear() tailJob = scope.launch(Dispatchers.IO) { @@ -39,35 +43,85 @@ class OutputStreamer( while (isActive) { var hadNewData = false 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 = String(bytes, Charsets.UTF_8) - onContent(text) - hadNewData = true + if (file.exists()) { + if (!headerSkipped) { + // Skip past the header to the STDOUT marker + val content = file.readText(Charsets.UTF_8) + val markerIdx = content.indexOf(STDOUT_MARKER) + if (markerIdx >= 0) { + fileOffset = (markerIdx + STDOUT_MARKER.length).toLong() + headerSkipped = true + } + } else 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 = filterContent(String(bytes, Charsets.UTF_8)) + if (text.isNotEmpty()) { + onContent(text) + hadNewData = true + } + } } } } catch (e: Exception) { LOG.debug("Error tailing log file: $logFilePath", e) } - // Poll faster when output is actively flowing, slower when idle 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 + if (path != null && headerSkipped) { + try { + val file = File(path) + 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 = filterContent(String(bytes, Charsets.UTF_8)) + if (text.isNotEmpty()) { + onContent(text) + } + } + } + } catch (e: Exception) { + LOG.debug("Error flushing log file: $path", e) + } + } + currentTaskId = null + currentLogPath = null + fileOffset = 0 + headerSkipped = false + } + fun stopTailing() { tailJob?.cancel() tailJob = null currentTaskId = null + currentLogPath = null fileOffset = 0 + headerSkipped = false } - fun showNoTask() { - stopTailing() - onNoTask() + private fun filterContent(text: String): String { + var result = text + // Strip everything from SUMMARY marker onwards + val summaryIdx = result.indexOf("--- SUMMARY ---") + if (summaryIdx >= 0) { + result = result.substring(0, summaryIdx).trimEnd('\n') + } + // Strip STDERR marker line but keep stderr content + result = result.replace("--- STDERR ---\n", "") + return result } } 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 index f21c90e..191a36c 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueTask.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/QueueTask.kt @@ -9,4 +9,8 @@ data class QueueTask( 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 index 3c006ca..f86c84d 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/TaskQueueModel.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/model/TaskQueueModel.kt @@ -29,6 +29,10 @@ class TaskQueueModel { fun update(newTasks: List) { tasks = newTasks summary = QueueSummary.fromTasks(newTasks) + notifyListeners() + } + + fun notifyListeners() { ApplicationManager.getApplication().invokeLater { ApplicationManager.getApplication().messageBus .syncPublisher(TOPIC) 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 index 7cf0b19..c294691 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/settings/TaskQueueConfigurable.kt @@ -1,5 +1,6 @@ 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 @@ -59,6 +60,7 @@ class TaskQueueConfigurable : Configurable { settings.dataDir = dataDirField?.text ?: return settings.displayMode = displayModes.getOrNull(displayModeCombo?.selectedIndex ?: -1) ?: "default" settings.notificationsEnabled = notificationsCheckbox?.isSelected ?: true + TaskQueueModel.getInstance().notifyListeners() } override fun reset() { 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 index c19591c..bf926a8 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt @@ -5,9 +5,7 @@ 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.execution.filters.TextConsoleBuilderFactory -import com.intellij.execution.ui.ConsoleView import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -19,12 +17,16 @@ import kotlinx.coroutines.SupervisorJob import java.awt.BorderLayout import javax.swing.JPanel -class OutputPanel(project: Project) : JPanel(BorderLayout()), Disposable { +class OutputPanel( + private val project: Project, + private val taskId: Int, + logFilePath: String, +) : JPanel(BorderLayout()), Disposable { - private val consoleView: ConsoleView = + private val consoleView = TextConsoleBuilderFactory.getInstance().createBuilder(project).console private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var currentRunningTaskId: Int? = null + private var finished = false private val streamer = OutputStreamer( scope = scope, @@ -38,43 +40,21 @@ class OutputPanel(project: Project) : JPanel(BorderLayout()), Disposable { consoleView.clear() } }, - onNoTask = { - ApplicationManager.getApplication().invokeLater { - consoleView.clear() - consoleView.print("No task running\n", ConsoleViewContentType.SYSTEM_OUTPUT) - } - } ) init { - Disposer.register(project, this) 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) { - val runningTask = tasks.firstOrNull { it.status == "running" } - handleRunningTask(runningTask) + if (!finished && tasks.none { it.id == taskId }) { + finished = true + streamer.finishTailing() + } } }) - - // Initial state - val runningTask = TaskQueueModel.getInstance().tasks.firstOrNull { it.status == "running" } - handleRunningTask(runningTask) - } - - private fun handleRunningTask(task: QueueTask?) { - if (task == null) { - currentRunningTaskId = null - streamer.showNoTask() - return - } - - if (task.id != currentRunningTaskId) { - currentRunningTaskId = task.id - val outputDir = TaskQueueSettings.getInstance().outputDir - val logPath = "$outputDir/task_${task.id}.log" - streamer.startTailing(task.id, logPath) - } } override fun dispose() { 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 index 55e6429..72ab37c 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueStatusBarWidget.kt @@ -31,7 +31,8 @@ class TaskQueueStatusBarWidget(private val project: Project) : CustomStatusBarWi toolTipText = "Agent Task Queue - Click to open" addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { - ToolWindowManager.getInstance(project).getToolWindow("Agent Task Queue")?.activate(null) + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Agent Task Queue") ?: return + if (toolWindow.isVisible) toolWindow.hide() else toolWindow.activate(null) } }) } @@ -81,7 +82,7 @@ class TaskQueueStatusBarWidget(private val project: Project) : CustomStatusBarWi else -> { val runningTask = tasks.firstOrNull { it.status == "running" } if (runningTask != null) { - val cmd = runningTask.command ?: "unknown" + val cmd = runningTask.displayCommand val truncatedCmd = cmd.take(40) + if (cmd.length > 40) "..." else "" when (mode) { "verbose" -> { 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 index f89cbf0..29d4047 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt @@ -1,6 +1,12 @@ 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 @@ -9,13 +15,37 @@ 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) - toolWindow.contentManager.addContent(queueContent) + queueContent.isCloseable = false + contentManager.addContent(queueContent) - val outputPanel = OutputPanel(project) - val outputContent = contentFactory.createContent(outputPanel, "Output", false) - toolWindow.contentManager.addContent(outputContent) + // Track which tasks already have output tabs + val taskTabIds = mutableSetOf() + + 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 + taskTabIds.add(runningTask.id) + + val tabTitle = runningTask.displayCommand.take(30) + + if (runningTask.displayCommand.length > 30) "..." else "" + val outputDir = TaskQueueSettings.getInstance().outputDir + val logPath = "$outputDir/task_${runningTask.id}.log" + + val outputPanel = OutputPanel(project, runningTask.id, logPath) + Disposer.register(contentManager, outputPanel) + val content = contentFactory.createContent(outputPanel, tabTitle, false) + content.isCloseable = true + content.setDisposer(outputPanel) + content.description = runningTask.displayCommand + contentManager.addContent(content) + contentManager.setSelectedContent(content) + } + }) } } diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin/src/main/resources/META-INF/plugin.xml index b235122..9443b43 100644 --- a/intellij-plugin/src/main/resources/META-INF/plugin.xml +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -63,6 +63,12 @@ text="View Output" description="Open the output log for the selected task" icon="AllIcons.Actions.Preview"/> + + From 7adae2446ea4dfcea521cb762a4c7a491130d946 Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Sat, 7 Feb 2026 17:38:43 -0500 Subject: [PATCH 04/10] Write raw output file for plugin streaming, add structured tool results Write task_.raw.log alongside formatted log so the IntelliJ plugin can tail pure build output without filtering metadata markers. Simplify OutputStreamer by removing header scanning and content filtering logic. Also add ToolResult structured content and tool annotations to run_task. Co-Authored-By: Claude Opus 4.6 --- .../agenttaskqueue/data/OutputStreamer.kt | 50 +++--------- .../ui/TaskQueueToolWindowFactory.kt | 2 +- task_queue.py | 77 ++++++++++++++++--- 3 files changed, 76 insertions(+), 53 deletions(-) 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 index 2b489f7..3051a44 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt @@ -20,14 +20,12 @@ class OutputStreamer( private val LOG = Logger.getInstance(OutputStreamer::class.java) private const val ACTIVE_POLL_MS = 50L private const val IDLE_POLL_MS = 200L - private const val STDOUT_MARKER = "--- STDOUT ---\n" } private var currentTaskId: Int? = null private var currentLogPath: String? = null private var fileOffset: Long = 0 private var tailJob: Job? = null - private var headerSkipped = false fun startTailing(taskId: Int, logFilePath: String) { if (taskId == currentTaskId) return @@ -35,7 +33,6 @@ class OutputStreamer( currentTaskId = taskId currentLogPath = logFilePath fileOffset = 0 - headerSkipped = false onClear() tailJob = scope.launch(Dispatchers.IO) { @@ -43,27 +40,14 @@ class OutputStreamer( while (isActive) { var hadNewData = false try { - if (file.exists()) { - if (!headerSkipped) { - // Skip past the header to the STDOUT marker - val content = file.readText(Charsets.UTF_8) - val markerIdx = content.indexOf(STDOUT_MARKER) - if (markerIdx >= 0) { - fileOffset = (markerIdx + STDOUT_MARKER.length).toLong() - headerSkipped = true - } - } else 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 = filterContent(String(bytes, Charsets.UTF_8)) - if (text.isNotEmpty()) { - onContent(text) - hadNewData = true - } - } + 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() + onContent(String(bytes, Charsets.UTF_8)) + hadNewData = true } } } catch (e: Exception) { @@ -79,7 +63,7 @@ class OutputStreamer( tailJob = null // Flush any remaining bytes written after the task finished val path = currentLogPath - if (path != null && headerSkipped) { + if (path != null) { try { val file = File(path) if (file.exists() && file.length() > fileOffset) { @@ -88,7 +72,7 @@ class OutputStreamer( val bytes = ByteArray((raf.length() - fileOffset).toInt()) raf.readFully(bytes) fileOffset = raf.length() - val text = filterContent(String(bytes, Charsets.UTF_8)) + val text = String(bytes, Charsets.UTF_8) if (text.isNotEmpty()) { onContent(text) } @@ -101,7 +85,6 @@ class OutputStreamer( currentTaskId = null currentLogPath = null fileOffset = 0 - headerSkipped = false } fun stopTailing() { @@ -110,18 +93,5 @@ class OutputStreamer( currentTaskId = null currentLogPath = null fileOffset = 0 - headerSkipped = false - } - - private fun filterContent(text: String): String { - var result = text - // Strip everything from SUMMARY marker onwards - val summaryIdx = result.indexOf("--- SUMMARY ---") - if (summaryIdx >= 0) { - result = result.substring(0, summaryIdx).trimEnd('\n') - } - // Strip STDERR marker line but keep stderr content - result = result.replace("--- STDERR ---\n", "") - return result } } 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 index 29d4047..1ace2e6 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt @@ -35,7 +35,7 @@ class TaskQueueToolWindowFactory : ToolWindowFactory { val tabTitle = runningTask.displayCommand.take(30) + if (runningTask.displayCommand.length > 30) "..." else "" val outputDir = TaskQueueSettings.getInstance().outputDir - val logPath = "$outputDir/task_${runningTask.id}.log" + val logPath = "$outputDir/task_${runningTask.id}.raw.log" val outputPanel = OutputPanel(project, runningTask.id, logPath) Disposer.register(contentManager, outputPanel) diff --git a/task_queue.py b/task_queue.py index f5003cb..072b507 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 ( @@ -197,7 +199,7 @@ def cleanup_output_files(): if not OUTPUT_DIR.exists(): return - files = sorted(OUTPUT_DIR.glob("task_*.log"), key=lambda f: f.stat().st_mtime) + files = sorted(OUTPUT_DIR.glob("task_*"), key=lambda f: f.stat().st_mtime) if len(files) > MAX_OUTPUT_FILES: for old_file in files[: len(files) - MAX_OUTPUT_FILES]: try: @@ -212,7 +214,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 +374,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: @@ -473,15 +489,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 +507,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 +530,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 +556,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 +587,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 From 298800afb9fa673d674e8ce14014bc0bdcbc8e0c Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Sat, 7 Feb 2026 17:59:09 -0500 Subject: [PATCH 05/10] Fix closeable output tabs and clean up stale tasks on poll Enable close buttons on output tabs via canCloseContents in plugin.xml. Detect and remove stale DB entries during polling by checking if the server PID is still alive, so cancelled tasks don't linger in the table. Co-Authored-By: Claude Opus 4.6 --- .../agenttaskqueue/data/TaskQueuePoller.kt | 26 ++++++++++++++++++- .../src/main/resources/META-INF/plugin.xml | 1 + 2 files changed, 26 insertions(+), 1 deletion(-) 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 index 9f58973..e83221e 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskQueuePoller.kt @@ -45,7 +45,19 @@ class TaskQueuePoller : Disposable { private fun poll() { try { - val tasks = TaskQueueDatabase.getInstance().fetchAllTasks() + 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) @@ -55,6 +67,18 @@ class TaskQueuePoller : Disposable { } } + 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) } diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin/src/main/resources/META-INF/plugin.xml index 9443b43..6586711 100644 --- a/intellij-plugin/src/main/resources/META-INF/plugin.xml +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -27,6 +27,7 @@ factoryClass="com.block.agenttaskqueue.ui.TaskQueueToolWindowFactory" anchor="bottom" secondary="true" + canCloseContents="true" icon="/icons/taskQueue.svg"/> From 2ced36bcf65231d1025aca89972a5542ad7d8781 Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Sat, 7 Feb 2026 18:01:43 -0500 Subject: [PATCH 06/10] Allow reopening output tabs by clicking running tasks in queue table Click a running task row to open or focus its output tab. Track tab closures so closed tabs can be reopened. Extract shared openOutputTab function used by both auto-open and click-to-open paths. Co-Authored-By: Claude Opus 4.6 --- .../block/agenttaskqueue/ui/OutputPanel.kt | 2 +- .../block/agenttaskqueue/ui/TaskQueuePanel.kt | 15 +++++ .../ui/TaskQueueToolWindowFactory.kt | 56 ++++++++++++++----- 3 files changed, 57 insertions(+), 16 deletions(-) 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 index bf926a8..ffe73d5 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/OutputPanel.kt @@ -19,7 +19,7 @@ import javax.swing.JPanel class OutputPanel( private val project: Project, - private val taskId: Int, + val taskId: Int, logFilePath: String, ) : JPanel(BorderLayout()), Disposable { 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 index 541d540..3d77818 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueuePanel.kt @@ -14,6 +14,8 @@ 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 { @@ -21,6 +23,7 @@ class TaskQueuePanel(private val project: Project) : JPanel(BorderLayout()), Dat 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 @@ -40,6 +43,18 @@ class TaskQueuePanel(private val project: Project) : JPanel(BorderLayout()), Dat 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) 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 index 1ace2e6..9d60888 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/ui/TaskQueueToolWindowFactory.kt @@ -10,6 +10,8 @@ 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 { @@ -26,26 +28,50 @@ class TaskQueueToolWindowFactory : ToolWindowFactory { // 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 - taskTabIds.add(runningTask.id) - - val tabTitle = runningTask.displayCommand.take(30) + - if (runningTask.displayCommand.length > 30) "..." else "" - val outputDir = TaskQueueSettings.getInstance().outputDir - val logPath = "$outputDir/task_${runningTask.id}.raw.log" - - val outputPanel = OutputPanel(project, runningTask.id, logPath) - Disposer.register(contentManager, outputPanel) - val content = contentFactory.createContent(outputPanel, tabTitle, false) - content.isCloseable = true - content.setDisposer(outputPanel) - content.description = runningTask.displayCommand - contentManager.addContent(content) - contentManager.setSelectedContent(content) + openOutputTab(runningTask) } }) + + // Click on a task in the queue table to open its output tab + queuePanel.onTaskClicked = { task -> openOutputTab(task) } } } From 463c56f9758b7e6425e64fae9ebb28b0e1bee485 Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Sat, 7 Feb 2026 18:15:14 -0500 Subject: [PATCH 07/10] Add version comments and fix raw log cleanup scaling Document that .raw.log is added in MCP server v0.4.0 and .log fallback is for v0.3.x and earlier. Scale MAX_OUTPUT_FILES by 2x in cleanup since each task now produces two files. Add OutputStreamer fallback from .raw.log to .log with filtering for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- .../agenttaskqueue/data/OutputStreamer.kt | 117 ++++++++++++++---- task_queue.py | 18 ++- 2 files changed, 110 insertions(+), 25 deletions(-) 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 index 3051a44..bfb6aee 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/OutputStreamer.kt @@ -10,6 +10,17 @@ 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, @@ -20,12 +31,16 @@ class OutputStreamer( 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 @@ -33,25 +48,67 @@ class OutputStreamer( currentTaskId = taskId currentLogPath = logFilePath fileOffset = 0 + useFallback = false onClear() tailJob = scope.launch(Dispatchers.IO) { - val file = File(logFilePath) + // 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.exists() && file.length() > fileOffset) { + 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() - onContent(String(bytes, Charsets.UTF_8)) - hadNewData = true + 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: $logFilePath", e) + LOG.debug("Error tailing log file: ${file.path}", e) } delay(if (hadNewData) ACTIVE_POLL_MS else IDLE_POLL_MS) } @@ -62,29 +119,36 @@ class OutputStreamer( tailJob?.cancel() tailJob = null // Flush any remaining bytes written after the task finished - val path = currentLogPath - if (path != null) { - try { - val file = File(path) - 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 = String(bytes, Charsets.UTF_8) - if (text.isNotEmpty()) { - onContent(text) - } + 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: $path", e) } + } catch (e: Exception) { + LOG.debug("Error flushing log file: ${file.path}", e) } currentTaskId = null currentLogPath = null fileOffset = 0 + useFallback = false } fun stopTailing() { @@ -93,5 +157,16 @@ class OutputStreamer( 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/task_queue.py b/task_queue.py index 072b507..2362b1c 100644 --- a/task_queue.py +++ b/task_queue.py @@ -195,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 + # 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) - if len(files) > MAX_OUTPUT_FILES: - for old_file in files[: len(files) - MAX_OUTPUT_FILES]: + # 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: @@ -466,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" From 06e076eeb3cd5b84dc87a55bf733ac31eff7d063 Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Mon, 9 Feb 2026 11:24:45 -0500 Subject: [PATCH 08/10] Update table immediately on cancel/clear with optimistic UI update Remove tasks from the model before starting background cleanup so the table responds instantly. The poller still reconciles with the DB on subsequent polls for cancellations from other sources (agents, other windows). Co-Authored-By: Claude Opus 4.6 --- .../com/block/agenttaskqueue/data/TaskCanceller.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index 6f62b1e..909ba5d 100644 --- a/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt +++ b/intellij-plugin/src/main/kotlin/com/block/agenttaskqueue/data/TaskCanceller.kt @@ -1,6 +1,7 @@ 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 @@ -16,6 +17,11 @@ class TaskCanceller { } 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) { @@ -30,6 +36,9 @@ class TaskCanceller { } fun clearAllTasks(tasks: List) { + // Optimistic UI update — clear the model immediately + TaskQueueModel.getInstance().update(emptyList()) + ApplicationManager.getApplication().executeOnPooledThread { try { for (task in tasks) { From 6deafedd006d5d4abbc10ca8ab1974bb2cda8d1e Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Mon, 9 Feb 2026 11:28:46 -0500 Subject: [PATCH 09/10] Update READMEs for raw log files, per-task tabs, and stale task cleanup Project README: - Document both .log and .raw.log output files with version info - Add command= to success/failure output examples - Add command and server_id to database schema table - Clarify cleanup is per-task (not per-file) Plugin README: - Describe per-task closeable output tabs instead of single Output tab - Document click-to-reopen, stale task detection, optimistic cancel - Document OutputStreamer raw log preference with .log fallback - Add OpenSettingsAction to project structure Co-Authored-By: Claude Opus 4.6 --- README.md | 16 ++++++++++++---- intellij-plugin/README.md | 18 +++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index cb39153..2f7190f 100644 --- a/README.md +++ b/README.md @@ -348,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 | @@ -387,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 index 4f68732..d358bce 100644 --- a/intellij-plugin/README.md +++ b/intellij-plugin/README.md @@ -19,10 +19,8 @@ Click the widget to open the tool window. Configure the display mode in **Settin ### Tool Window -Two tabs: - -- **Queue** — Table of all tasks with ID, status, queue name, command, and relative time. Toolbar actions for refresh, cancel, clear, and view output. -- **Output** — Live streaming console view of the currently running task's output. Automatically switches when a new task starts running. +- **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 @@ -34,7 +32,7 @@ Balloon notifications for queue events (can be disabled in settings): | 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 task's output log after it disappears from the queue. +Failure detection works by reading the `EXIT CODE` from the formatted task log (`task_.log`) after it disappears from the queue. ## Architecture @@ -57,12 +55,15 @@ Two independent polling loops, each active only when needed: - 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 log file: +**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. @@ -87,6 +88,8 @@ All UI components subscribe to `TaskQueueModel.TOPIC` on the IntelliJ message bu 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: @@ -102,7 +105,7 @@ The plugin reads from the `queue` table: | `created_at` | TIMESTAMP | When task was queued | | `updated_at` | TIMESTAMP | Last status change | -Output logs are at `/output/task_.log`. +Output logs are at `/output/task_.log` (formatted) and `/output/task_.raw.log` (raw output, MCP server v0.4.0+). ## Building @@ -142,6 +145,7 @@ src/main/kotlin/com/block/agenttaskqueue/ │ ├── 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/ From 901998ce6fccb561127d4bb578e9ab355d5dd276 Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Fri, 13 Feb 2026 17:55:17 -0500 Subject: [PATCH 10/10] Fix output rotation tests to account for .raw.log companion files Co-Authored-By: Claude Opus 4.6 --- tests/test_queue.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) 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:]: